339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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 1–100,000/u,
|
||
);
|
||
expect(() => randomIdentifiers(source, 100_001, "uuid7", 0)).toThrow(
|
||
/safe integer from 1–100,000/u,
|
||
);
|
||
expect(() => randomIdentifiers(source, Number.NaN, "ulid", 0)).toThrow(
|
||
/safe integer from 1–100,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(/1–52/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(/0–10/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("commit–reveal 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 });
|
||
});
|
||
});
|