mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 22:30:59 +01:00
The two shared packages are the first repos on the preset (link:../ts_config inside the monorepo; consumers outside use the git tag). Each eslint.config.js is now the defineConfig() call plus one override with its reason: a public API name (`nextChance`, `fuzzyMatch`) the boolean-prefix rule would rename, which is a breaking change for the three repos importing it. Everything else the preset flagged is fixed in code, not disabled: key order (perfectionist), hex casing, sort compare functions in tests, Iterator#map().toArray(), explicit returns on multiline arrows. web_ui: react-refresh/only-export-components was a real finding -- range-slider.tsx exported `steppedValue` next to the component, which breaks Vite HMR for the module. The keyboard contract moves to stepped-value.ts as `steppedValue` + a new pure `keyTarget(key, values, current)`, which also resolves unicorn/no-late-event-control: the handler now decides the target first and calls preventDefault() only when it owns the key. Its tests move with it (the 250-line gate caught the combined file). Minor bump for the new export. dist/ rebuilt in both packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnAYyNH1Sm4iJNidfYbrZJ
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
createRng,
|
|
createSeededRng,
|
|
nextChance,
|
|
nextFloat,
|
|
nextInt,
|
|
pick,
|
|
} from './rng.ts'
|
|
|
|
/**
|
|
* The golden sequence for seed 12345, captured from the *existing* konbini-67
|
|
* and sims3-clone implementations before extraction.
|
|
*
|
|
* This is the load-bearing test in the package. These repos guarantee "same
|
|
* seed -> identical world"; if the shared core ever stops reproducing this
|
|
* exact sequence, every saved seed in every consumer silently generates a
|
|
* different world. Regenerating these numbers to make a failure go away
|
|
* would defeat the entire point of the test.
|
|
*/
|
|
const GOLDEN_12345 = [
|
|
0.9797282677609473, 0.3067522644996643, 0.484205421525985,
|
|
0.817934412509203, 0.5094283693470061,
|
|
]
|
|
|
|
describe('mulberry32 core', () => {
|
|
it('reproduces the pre-extraction sequence for a known seed', () => {
|
|
const rng = createRng(12_345)
|
|
const seen = Array.from({ length: 5 }, () => nextFloat(rng))
|
|
expect(seen).toEqual(GOLDEN_12345)
|
|
})
|
|
|
|
it('gives both interfaces the same sequence', () => {
|
|
const free = createRng(999)
|
|
const closure = createSeededRng(999)
|
|
const a = Array.from({ length: 8 }, () => nextFloat(free))
|
|
const b = Array.from({ length: 8 }, () => closure.float())
|
|
expect(a).toEqual(b)
|
|
})
|
|
|
|
it('is reproducible: same seed, same draws', () => {
|
|
const a = createRng(7)
|
|
const b = createRng(7)
|
|
expect(nextFloat(a)).toBe(nextFloat(b))
|
|
})
|
|
|
|
it('differs between seeds', () => {
|
|
expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
|
|
})
|
|
|
|
it('stays within [0, 1) across many draws', () => {
|
|
const rng = createRng(42)
|
|
for (let index = 0; index < 1000; index += 1) {
|
|
const value = nextFloat(rng)
|
|
expect(value).toBeGreaterThanOrEqual(0)
|
|
expect(value).toBeLessThan(1)
|
|
}
|
|
})
|
|
|
|
it('handles a seed of 0 and a negative seed', () => {
|
|
expect(nextFloat(createRng(0))).toBeGreaterThanOrEqual(0)
|
|
expect(nextFloat(createRng(-1))).toBeGreaterThanOrEqual(0)
|
|
})
|
|
})
|
|
|
|
describe('nextInt', () => {
|
|
it('is inclusive of both bounds', () => {
|
|
const rng = createRng(3)
|
|
const seen = new Set<number>()
|
|
for (let index = 0; index < 500; index += 1) {
|
|
seen.add(nextInt(rng, 1, 3))
|
|
}
|
|
expect([...seen].toSorted((a, b) => a - b)).toEqual([1, 2, 3])
|
|
})
|
|
|
|
it('returns the only value when min equals max', () => {
|
|
expect(nextInt(createRng(5), 4, 4)).toBe(4)
|
|
})
|
|
|
|
it('consumes exactly one step, like a raw float draw', () => {
|
|
const a = createRng(11)
|
|
const b = createRng(11)
|
|
nextInt(a, 0, 9)
|
|
nextFloat(b)
|
|
expect(nextFloat(a)).toBe(nextFloat(b))
|
|
})
|
|
})
|
|
|
|
describe('nextChance', () => {
|
|
it('is always true at probability 1 and never at 0', () => {
|
|
const rng = createRng(8)
|
|
expect(nextChance(rng, 1)).toBe(true)
|
|
expect(nextChance(rng, 0)).toBe(false)
|
|
})
|
|
|
|
it('lands near the requested probability over many draws', () => {
|
|
const rng = createRng(2024)
|
|
let hits = 0
|
|
for (let index = 0; index < 4000; index += 1) {
|
|
if (nextChance(rng, 0.25)) {
|
|
hits += 1
|
|
}
|
|
}
|
|
expect(hits / 4000).toBeGreaterThan(0.2)
|
|
expect(hits / 4000).toBeLessThan(0.3)
|
|
})
|
|
})
|
|
|
|
describe('pick', () => {
|
|
it('returns the single element of a one-item tuple', () => {
|
|
expect(pick(createRng(1), ['only'])).toBe('only')
|
|
})
|
|
|
|
it('eventually returns every element', () => {
|
|
const rng = createRng(4)
|
|
const seen = new Set<string>()
|
|
for (let index = 0; index < 500; index += 1) {
|
|
seen.add(pick(rng, ['a', 'b', 'c']))
|
|
}
|
|
expect([...seen].toSorted((a, b) => a.localeCompare(b))).toEqual(['a', 'b', 'c'])
|
|
})
|
|
|
|
it('is deterministic for a given seed', () => {
|
|
const items: readonly [string, ...string[]] = ['w', 'x', 'y', 'z']
|
|
expect(pick(createRng(77), items)).toBe(pick(createRng(77), items))
|
|
})
|
|
})
|
|
|
|
describe('createSeededRng', () => {
|
|
it('picks uniformly and deterministically', () => {
|
|
expect(createSeededRng(5).pick(['p', 'q', 'r'])).toBe(
|
|
createSeededRng(5).pick(['p', 'q', 'r']),
|
|
)
|
|
})
|
|
|
|
it('throws rather than returning undefined on an empty array', () => {
|
|
expect(() => createSeededRng(1).pick([])).toThrow('pick() on an empty array')
|
|
})
|
|
|
|
it('matches europe-county-map: pick indexes by floor(float * len)', () => {
|
|
// The old local implementation indexed directly off the float rather than
|
|
// going through int(). Same step count, and algebraically identical for
|
|
// 0..len-1 -- pinned here so a future "simplification" to int() cannot
|
|
// silently shift every pick by an off-by-one.
|
|
const probe = createRng(31)
|
|
const expectedIndex = Math.floor(nextFloat(probe) * 5)
|
|
const items = ['a', 'b', 'c', 'd', 'e']
|
|
expect(createSeededRng(31).pick(items)).toBe(items[expectedIndex])
|
|
})
|
|
|
|
it('exposes int and chance over the same stream', () => {
|
|
const rng = createSeededRng(64)
|
|
expect(rng.int(10, 10)).toBe(10)
|
|
expect(typeof rng.chance(0.5)).toBe('boolean')
|
|
})
|
|
})
|