You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
101 lines
4.1 KiB
101 lines
4.1 KiB
import { describe, expect, test } from '@jest/globals'
|
|
import { shuffleWeightedList, WeightableItem, WeightedList } from './weightedList'
|
|
import { forbiddenRNG } from './random'
|
|
import { default as seedrandom } from 'seedrandom'
|
|
|
|
type NonRandomTestCase<ItemT extends WeightableItem> = readonly [string, ItemT | WeightedList<ItemT>, ItemT, ItemT]
|
|
// type RandomTestCase<ItemT extends WeightableItem> = readonly [string, ItemT | WeightedList<ItemT>, ItemT, number, ItemT]
|
|
|
|
describe('shuffleWeightedList', () => {
|
|
test.each<NonRandomTestCase<string>>([
|
|
['returns empty value for empty list', [], 'empty', 'empty'],
|
|
['returns item value for lone item', 'text', 'empty', 'text'],
|
|
['returns single item for singleton list', ['wrapped'], 'empty', 'wrapped'],
|
|
['returns single weighted item for singleton list', [{ wt: 10, v: 'found' }], 'empty', 'found'],
|
|
[
|
|
'returns lone item with nonzero weight',
|
|
[
|
|
{ wt: 0, v: 'zero' },
|
|
{ wt: 2, v: 'goal' },
|
|
{ wt: 0, v: 'none' },
|
|
],
|
|
'empty',
|
|
'goal',
|
|
],
|
|
[
|
|
'returns lone item without zero weight',
|
|
[{ wt: 0, v: 'zero' }, 'where am i', { wt: 0, v: 'none' }],
|
|
'empty',
|
|
'where am i',
|
|
],
|
|
[
|
|
'returns empty for list with all zero weights',
|
|
[
|
|
{ wt: 0, v: 'zero' },
|
|
{ wt: 0, v: 'goal??' },
|
|
{ wt: 0, v: 'none' },
|
|
],
|
|
'its basically empty',
|
|
'its basically empty',
|
|
],
|
|
[
|
|
'returns nested singleton for list with all zero weights save nested list',
|
|
[
|
|
{ wt: 0, v: 'zero' },
|
|
{ wt: 1, v: [{ wt: 1, v: ['goal!!!'] }] },
|
|
{ wt: 0, v: 'none' },
|
|
],
|
|
'its basically empty',
|
|
'goal!!!',
|
|
],
|
|
[
|
|
'returns nested singleton for list with all zero weights save nested list without weight',
|
|
[{ wt: 0, v: 'zero' }, [{ wt: 1, v: [{ wt: 1, v: 'goal!?!' }] }], { wt: 0, v: 'none' }],
|
|
'hell',
|
|
'goal!?!',
|
|
],
|
|
])(' %s (%j with default %j -> %j)', (caseName, input, empty, result) => {
|
|
expect(shuffleWeightedList(input, empty, forbiddenRNG)).toEqual(result)
|
|
})
|
|
|
|
test(' chooses a value roughly evenly at random from the options for an unweighted list', () => {
|
|
const input: WeightedList<'a' | 'b' | 'c' | 'd' | ''> = ['a', 'b', 'c', 'd']
|
|
const random = seedrandom('poppy')
|
|
const counts = { a: 0, b: 0, c: 0, d: 0, '': 0 }
|
|
const totalRounds = 10_000
|
|
for (let roundsLeft = totalRounds; roundsLeft > 0; roundsLeft -= 1) {
|
|
const result = shuffleWeightedList(input, '', random)
|
|
counts[result] = (counts[result] ?? 0) + 1
|
|
}
|
|
expect(counts['']).toBe(0)
|
|
for (const key of ['a', 'b', 'c', 'd'] as ('a' | 'b' | 'c' | 'd')[]) {
|
|
// Confirm everything is between 24-26% after 10,000 rounds
|
|
expect(Math.abs(25 - (100 * counts[key]) / totalRounds)).toBeLessThan(1)
|
|
}
|
|
})
|
|
|
|
test(' chooses a value roughly evenly at random from the options for a weighted list', () => {
|
|
const input: WeightedList<'a' | 'b' | 'c' | 'd'> = [
|
|
{ wt: 2, v: 'a' },
|
|
{ wt: 5, v: 'b' },
|
|
{ wt: 0, v: 'c' },
|
|
'd',
|
|
]
|
|
const random = seedrandom('sesame')
|
|
const counts = { a: 0, b: 0, c: 0, d: 0 }
|
|
const totalRounds = 10_000
|
|
for (let roundsLeft = totalRounds; roundsLeft > 0; roundsLeft -= 1) {
|
|
const result = shuffleWeightedList(input, 'c', random)
|
|
counts[result] = (counts[result] ?? 0) + 1
|
|
}
|
|
expect(counts['c']).toBe(0)
|
|
for (const [expected, key] of [
|
|
[25, 'a'],
|
|
[62.5, 'b'],
|
|
[12.5, 'd'],
|
|
] as [number, 'a' | 'b' | 'd'][]) {
|
|
// Confirm everything is within 1% of its expected weight after 10,000 rounds
|
|
expect(Math.abs(expected - (100 * counts[key]) / totalRounds)).toBeLessThan(1)
|
|
}
|
|
})
|
|
})
|
|
|