Files
rand-tools/tests/random/core.test.ts
T
zemion 4f61d001ab
Verify / verify (push) Canceled after 0s
Release Random Tools 0.2.0
2026-09-02 12:28:20 +02:00

339 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
normalizeWordList,
passphrase,
passphraseWithWordList,
randomIdentifiers,
randomIntegers,
randomString,
rollDice,
ulid,
uuidV4,
uuidV7,
} from "../../src/random/generators";
import {
coinFlips,
dealCards,
decimalFractions,
integerSequence,
randomCoordinates,
randomDates,
} from "../../src/random/draws";
import { randomSource } from "../../src/random/source";
import {
commitmentForReveal,
createCeremonyReveal,
finalizeCeremony,
parseCeremonyDocument,
} from "../../src/random/ceremony";
import { parseSeededRecipe, runSeededRecipe } from "../../src/random/recipes";
import {
parseWeightedItems,
weightedSampleWithoutReplacement,
} from "../../src/random/weighted";
describe("random generators", () => {
it("repeats deterministic recipes", () =>
expect(
randomIntegers(randomSource("deterministic", "seed"), 10, 1, 6),
).toEqual(randomIntegers(randomSource("deterministic", "seed"), 10, 1, 6)));
it("produces RFC-shaped UUIDs", () => {
const source = randomSource("deterministic", "id");
expect(uuidV4(source)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
);
expect(uuidV7(source, 0)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
);
});
it("creates a 26-character ULID", () =>
expect(ulid(randomSource("deterministic", "id"), 0)).toMatch(
/^[0-9A-HJKMNP-TV-Z]{26}$/u,
));
it("bounds identifier batches before allocating their output array", () => {
const source = randomSource("deterministic", "identifier-bounds");
expect(() => randomIdentifiers(source, 0, "uuid4", 0)).toThrow(
/safe integer from 1100,000/u,
);
expect(() => randomIdentifiers(source, 100_001, "uuid7", 0)).toThrow(
/safe integer from 1100,000/u,
);
expect(() => randomIdentifiers(source, Number.NaN, "ulid", 0)).toThrow(
/safe integer from 1100,000/u,
);
expect(randomIdentifiers(source, 2, "uuid4", 0)).toHaveLength(2);
});
it("rejects an inclusive MAX_SAFE_INTEGER bound before adding one", () => {
const source = randomSource("deterministic", "integer-boundary");
expect(() =>
randomIntegers(
source,
1,
Number.MAX_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER,
),
).toThrow(/less than Number\.MAX_SAFE_INTEGER/u);
expect(
randomIntegers(
source,
1,
Number.MAX_SAFE_INTEGER - 1,
Number.MAX_SAFE_INTEGER - 1,
),
).toEqual([Number.MAX_SAFE_INTEGER - 1]);
});
it("rejects biased duplicate alphabets", () =>
expect(() =>
randomString(randomSource("deterministic", "x"), 3, "aab"),
).toThrow(/duplicate/u));
it("bounds dice and reports the modifier", () =>
expect(
rollDice(randomSource("deterministic", "dice"), "4d6+2"),
).toMatchObject({ expression: "4d6+2", modifier: 2 }));
it("reports only the uniform-list entropy model", () =>
expect(
passphrase(randomSource("deterministic", "words"), 4, [
"one",
"two",
"three",
"four",
]).entropy,
).toBe(8));
it("normalizes a custom word list once and reports its eligible count", () => {
const words = normalizeWordList(" one \r\n\r\ntwo\n three ");
expect(words).toMatchObject({
words: ["one", "two", "three"],
canonical: "one\ntwo\nthree",
normalization: "trim-lines-drop-empty-preserve-order-v1",
});
expect(
passphraseWithWordList(
randomSource("deterministic", "normalized-words"),
4,
words,
),
).toMatchObject({ listSize: 3, entropy: 4 * Math.log2(3) });
});
it("bounds alphabet and word-list allocation before generation", () => {
const source = randomSource("deterministic", "bounds");
expect(() => randomString(source, 1, "ab".repeat(70_000))).toThrow(
/131,072/u,
);
expect(() => passphrase(source, 1, ["x".repeat(10_001), "y"])).toThrow(
/10,000/u,
);
});
});
describe("local draws", () => {
it("reproduces coin flips and reports only binary outcomes", () => {
const first = coinFlips(randomSource("deterministic", "coins"), 50);
const second = coinFlips(randomSource("deterministic", "coins"), 50);
expect(first).toEqual(second);
expect(first.every((value) => value === "Heads" || value === "Tails")).toBe(
true,
);
});
it("deals every card from a standard deck without replacement", () => {
const cards = dealCards(randomSource("deterministic", "cards"), 1, 52);
expect(cards).toHaveLength(52);
expect(new Set(cards).size).toBe(52);
expect(
cards.every((card) => /^(?:A|[2-9]|10|J|Q|K)[♠♥♦♣]$/u.test(card)),
).toBe(true);
});
it("shuffles an inclusive integer range exactly once", () => {
const values = integerSequence(
randomSource("deterministic", "sequence"),
-2,
3,
);
expect(values).toHaveLength(6);
expect([...values].sort((a, b) => a - b)).toEqual([-2, -1, 0, 1, 2, 3]);
});
it("filters dates by weekday and can sample without replacement", () => {
const values = randomDates(
randomSource("deterministic", "dates"),
4,
"2024-02-01",
"2024-02-29",
{ weekdays: [1], withoutReplacement: true },
);
expect(values).toHaveLength(4);
expect(new Set(values).size).toBe(4);
expect(
values.every((value) => new Date(`${value}T00:00:00Z`).getUTCDay() === 1),
).toBe(true);
expect(() =>
randomDates(
randomSource("deterministic", "bad-date"),
1,
"2023-02-29",
"2023-03-01",
),
).toThrow(/valid Gregorian/u);
});
it("generates exact-length decimal fractions with no binary formatting", () => {
const values = decimalFractions(
randomSource("deterministic", "fractions"),
4,
12,
);
expect(values).toHaveLength(4);
expect(values.every((value) => /^0\.\d{12}$/u.test(value))).toBe(true);
});
it("generates reproducible coordinates inside geographic bounds", () => {
const first = randomCoordinates(
randomSource("deterministic", "coordinates"),
100,
6,
);
const second = randomCoordinates(
randomSource("deterministic", "coordinates"),
100,
6,
);
expect(first).toEqual(second);
expect(
first.every(
({ latitude, longitude }) =>
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180,
),
).toBe(true);
});
it("rejects draw requests beyond their allocation bounds", () => {
const source = randomSource("deterministic", "bounds");
expect(() => coinFlips(source, 100_001)).toThrow(/100,000/u);
expect(() => dealCards(source, 1, 53)).toThrow(/152/u);
expect(() => integerSequence(source, 0, 100_000)).toThrow(/100,000/u);
expect(() => decimalFractions(source, 100_000, 64)).toThrow(/4,000,000/u);
expect(() => randomCoordinates(source, 1, 11)).toThrow(/010/u);
});
});
describe("weighted draws and recipes", () => {
it("parses quoted CSV and samples entries without replacement", () => {
const items = parseWeightedItems('"Alpha, Inc.",1\nBeta,10\nGamma,2');
const first = weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
items,
3,
);
const second = weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
items,
3,
);
expect(first).toEqual(second);
expect(new Set(first.map((item) => item.inputIndex)).size).toBe(3);
expect(items[0]?.value).toBe("Alpha, Inc.");
});
it("rejects invalid weights before drawing", () => {
expect(() => parseWeightedItems("Alpha,0")).toThrow(/between/u);
expect(() =>
weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
[{ value: "Alpha", weight: Number.POSITIVE_INFINITY }],
1,
),
).toThrow(/invalid/u);
});
it("validates and exactly replays a versioned seeded recipe", () => {
const source = JSON.stringify({
schemaVersion: 1,
algorithm: "weighted-sample-v1",
seed: "recipe-seed",
parameters: {
count: 2,
items: [
{ value: "one", weight: 1 },
{ value: "two", weight: 4 },
{ value: "three", weight: 2 },
],
},
});
const recipe = parseSeededRecipe(source);
expect(runSeededRecipe(recipe)).toEqual(runSeededRecipe(recipe));
expect(runSeededRecipe(recipe).output).toHaveLength(2);
});
});
describe("commitreveal ceremonies", () => {
it("verifies commitments and derives an order-independent final seed", async () => {
const alice = await createCeremonyReveal("draw-1", "Alice");
const bob = await createCeremonyReveal("draw-1", "Bob");
const first = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-1",
participants: [alice, bob],
});
const second = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-1",
participants: [bob, alice],
});
expect(first.valid).toBe(true);
expect(first.seed).toMatch(/^[A-Za-z0-9_-]{43}$/u);
expect(second.seed).toBe(first.seed);
});
it("rejects a reveal changed after commitment", async () => {
const entry = await createCeremonyReveal("draw-2", "Alice");
const other = await createCeremonyReveal("draw-2", "Other");
const result = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-2",
participants: [{ ...entry, nonce: other.nonce }],
});
expect(result).toMatchObject({ valid: false, verified: 0 });
expect(result.errors.join(" ")).toMatch(/does not match/u);
});
it("parses a bounded document and reproduces its commitment", async () => {
const entry = await createCeremonyReveal("draw-3", "Alice");
const parsed = parseCeremonyDocument(
JSON.stringify({
schemaVersion: 1,
ceremonyId: "draw-3",
participants: [entry],
}),
);
await expect(
commitmentForReveal("draw-3", "Alice", parsed.participants[0]!.nonce),
).resolves.toMatchObject({ commitment: entry.commitment });
});
it("retains per-reveal ceremony IDs and rejects malformed commitments", async () => {
const entry = await createCeremonyReveal("draw-4", "Alice");
const parsed = parseCeremonyDocument(
JSON.stringify({
schemaVersion: 1,
ceremonyId: "draw-4",
participants: [{ ...entry, ceremonyId: "another-draw" }],
}),
);
await expect(finalizeCeremony(parsed)).resolves.toMatchObject({
valid: false,
verified: 0,
});
await expect(
finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-4",
participants: [{ ...entry, commitment: "not-base64url" }],
}),
).resolves.toMatchObject({ valid: false, verified: 0 });
});
});