diff --git a/src/__tests__/args.test.ts b/src/__tests__/args.test.ts new file mode 100644 index 0000000..bc862a8 --- /dev/null +++ b/src/__tests__/args.test.ts @@ -0,0 +1,171 @@ +import { describe, test, expect } from "bun:test"; +import { parseArgs, applyPositionals } from "../args"; +import { defaultOptions, type Options } from "../types"; + +describe("parseArgs", () => { + test("no args → defaults", () => { + const { options, help, version } = parseArgs([]); + expect(help).toBe(false); + expect(version).toBe(false); + expect(options.dist).toBe("uniform"); + expect(options.count).toBe(1); + expect(options.decimals).toBe(0); + expect(options.min).toBe(0); + expect(options.max).toBe(100); + }); + + test("-h / --help", () => { + expect(parseArgs(["-h"]).help).toBe(true); + expect(parseArgs(["--help"]).help).toBe(true); + }); + + test("-V / --version", () => { + expect(parseArgs(["-V"]).version).toBe(true); + expect(parseArgs(["--version"]).version).toBe(true); + }); + + test("-c / --count", () => { + expect(parseArgs(["-c", "5"]).options.count).toBe(5); + expect(parseArgs(["--count", "10"]).options.count).toBe(10); + }); + + test("-c with invalid value throws", () => { + expect(() => parseArgs(["-c", "0"])).toThrow(); + expect(() => parseArgs(["-c", "-1"])).toThrow(); + expect(() => parseArgs(["-c", "1000001"])).toThrow(); + }); + + test("-f with no arg defaults to 2", () => { + expect(parseArgs(["-f"]).options.decimals).toBe(2); + }); + + test("-f with space-separated arg", () => { + expect(parseArgs(["-f", "3"]).options.decimals).toBe(3); + expect(parseArgs(["-f", "0"]).options.decimals).toBe(0); + }); + + test("-fN combined form", () => { + expect(parseArgs(["-f1"]).options.decimals).toBe(1); + expect(parseArgs(["-f5"]).options.decimals).toBe(5); + expect(parseArgs(["-f0"]).options.decimals).toBe(0); + }); + + test("--float with no arg defaults to 2", () => { + expect(parseArgs(["--float"]).options.decimals).toBe(2); + }); + + test("--float with arg", () => { + expect(parseArgs(["--float", "4"]).options.decimals).toBe(4); + }); + + test("-d / --dist", () => { + expect(parseArgs(["-d", "normal"]).options.dist).toBe("normal"); + expect(parseArgs(["--dist", "poisson"]).options.dist).toBe("poisson"); + }); + + test("-d with invalid dist throws", () => { + expect(() => parseArgs(["-d", "invalid"])).toThrow(); + }); + + test("positional args for uniform", () => { + const { options } = parseArgs(["10"]); + expect(options.max).toBe(10); + expect(options.min).toBe(0); + }); + + test("positional args for uniform with two values", () => { + const { options } = parseArgs(["10", "20"]); + expect(options.min).toBe(10); + expect(options.max).toBe(20); + }); + + test("positional args for normal", () => { + const { options } = parseArgs(["-d", "normal", "100", "15"]); + expect(options.mean).toBe(100); + expect(options.stddev).toBe(15); + }); + + test("-- separator passes everything as positional", () => { + const { options } = parseArgs(["--", "-10", "-5"]); + expect(options.min).toBe(-10); + expect(options.max).toBe(-5); + }); + + test("-- separator stops flag parsing", () => { + // numbers after -- become positional + const { options } = parseArgs(["--", "5"]); + expect(options.max).toBe(5); + // non-numeric after -- still throws + expect(() => parseArgs(["--", "-h"])).toThrow("not a number"); + }); + + test("unknown flag throws", () => { + expect(() => parseArgs(["--unknown"])).toThrow("unknown option"); + }); +}); + +describe("applyPositionals", () => { + function makeOpts(dist: string, overrides?: Partial): Options { + return { ...defaultOptions(dist as Options["dist"]), dist: dist as Options["dist"], ...overrides }; + } + + test("uniform: 0 args leaves defaults", () => { + const o = makeOpts("uniform"); + applyPositionals(o, []); + expect(o.min).toBe(0); + expect(o.max).toBe(100); + }); + + test("uniform: 1 arg sets max", () => { + const o = makeOpts("uniform"); + applyPositionals(o, ["50"]); + expect(o.max).toBe(50); + }); + + test("uniform: 2 args sets min and max", () => { + const o = makeOpts("uniform"); + applyPositionals(o, ["10", "20"]); + expect(o.min).toBe(10); + expect(o.max).toBe(20); + }); + + test("uniform: min > max throws", () => { + const o = makeOpts("uniform"); + expect(() => applyPositionals(o, ["10", "5"])).toThrow("min"); + }); + + test("uniform: too many args throws", () => { + const o = makeOpts("uniform"); + expect(() => applyPositionals(o, ["1", "2", "3"])).toThrow("uniform expects"); + }); + + test("normal: stddev <= 0 throws", () => { + const o = makeOpts("normal"); + expect(() => applyPositionals(o, ["0", "-1"])).toThrow("stddev"); + }); + + test("binomial: prob out of range throws", () => { + const o = makeOpts("binomial"); + expect(() => applyPositionals(o, ["10", "1.5"])).toThrow("prob"); + }); + + test("poisson: lambda <= 0 throws", () => { + const o = makeOpts("poisson"); + expect(() => applyPositionals(o, ["0"])).toThrow("lambda"); + }); + + test("poisson: too many args throws", () => { + const o = makeOpts("poisson"); + expect(() => applyPositionals(o, ["1", "2"])).toThrow("poisson expects"); + }); + + test("exponential: lambda <= 0 throws", () => { + const o = makeOpts("exponential"); + expect(() => applyPositionals(o, ["0"])).toThrow("lambda"); + }); + + test("hypergeometric: K > N throws", () => { + const o = makeOpts("hypergeometric"); + expect(() => applyPositionals(o, ["100", "150", "10"])).toThrow("K"); + }); +}); diff --git a/src/__tests__/dist.test.ts b/src/__tests__/dist.test.ts new file mode 100644 index 0000000..3a62c77 --- /dev/null +++ b/src/__tests__/dist.test.ts @@ -0,0 +1,214 @@ +import { describe, test, expect } from "bun:test"; +import { generate } from "../dist"; +import { type Options, type Dist, defaultOptions } from "../types"; + +function opts(overrides: Partial & { dist: Dist }): Options { + return { ...defaultOptions(overrides.dist), ...overrides }; +} + +describe("generate", () => { + test("uniform produces values in [min, max] for decimals=0 (inclusive)", () => { + const n = 2000; + const o = opts({ dist: "uniform", min: 10, max: 20, count: n, decimals: 0 }); + const values = [...generate(o)]; + expect(values.length).toBe(n); + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(10); + expect(v).toBeLessThanOrEqual(20); + expect(Number.isInteger(v)).toBe(true); + } + // All values in [10,20] must appear at least once with high probability + const seen = new Set(values); + for (let i = 10; i <= 20; i++) expect(seen.has(i)).toBe(true); + }); + + test("uniform produces values in [min, max) for decimals>0", () => { + const o = opts({ dist: "uniform", min: 0, max: 1, count: 500, decimals: 4 }); + const values = [...generate(o)]; + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + test("uniform negative range produces inclusive values", () => { + const o = opts({ dist: "uniform", min: -10, max: -5, count: 100, decimals: 0 }); + const values = [...generate(o)]; + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(-10); + expect(v).toBeLessThanOrEqual(-5); + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("uniform single-value range", () => { + const o = opts({ dist: "uniform", min: 7, max: 7, count: 50, decimals: 0 }); + const values = [...generate(o)]; + for (const v of values) expect(v).toBe(7); + }); + + test("normal produces values around mean", () => { + const o = opts({ dist: "normal", mean: 100, stddev: 15, count: 2000 }); + const values = [...generate(o)]; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + // Mean should be within ~3 std errors of 100 + expect(avg).toBeGreaterThan(98); + expect(avg).toBeLessThan(102); + }); + + test("normal with decimals=0 produces integers via Math.round", () => { + const o = opts({ dist: "normal", mean: 0, stddev: 1, count: 500, decimals: 0 }); + const values = [...generate(o)]; + for (const v of values) { + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("binomial produces values in [0, n]", () => { + const o = opts({ dist: "binomial", trials: 10, prob: 0.5, count: 500 }); + const values = [...generate(o)]; + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(10); + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("binomial p=0 always returns 0", () => { + const o = opts({ dist: "binomial", trials: 10, prob: 0, count: 50 }); + for (const v of generate(o)) expect(v).toBe(0); + }); + + test("binomial p=1 always returns n", () => { + const o = opts({ dist: "binomial", trials: 10, prob: 1, count: 50 }); + for (const v of generate(o)) expect(v).toBe(10); + }); + + test("binomial trials=0 always returns 0", () => { + const o = opts({ dist: "binomial", trials: 0, prob: 0.5, count: 50 }); + for (const v of generate(o)) expect(v).toBe(0); + }); + + test("binomial large n uses Normal approximation", () => { + const o = opts({ dist: "binomial", trials: 20000, prob: 0.5, count: 200 }); + const values = [...generate(o)]; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + expect(avg).toBeGreaterThan(9500); + expect(avg).toBeLessThan(10500); + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(20000); + } + }); + + test("poisson produces non-negative integers", () => { + const o = opts({ dist: "poisson", lambda: 5, count: 500 }); + const values = [...generate(o)]; + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("poisson large lambda uses Normal approximation", () => { + const o = opts({ dist: "poisson", lambda: 20000, count: 200 }); + const values = [...generate(o)]; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + expect(avg).toBeGreaterThan(19000); + expect(avg).toBeLessThan(21000); + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("exponential produces non-negative values", () => { + // decimals=0: Math.round can make very small values become 0 + const o = opts({ dist: "exponential", lambda: 1, count: 500 }); + for (const v of generate(o)) { + expect(v).toBeGreaterThanOrEqual(0); + } + }); + + test("exponential mean approximates 1/lambda", () => { + const lambda = 2; + const o = opts({ dist: "exponential", lambda, count: 2000 }); + const values = [...generate(o)]; + const avg = values.reduce((a, b) => a + b, 0) / values.length; + expect(avg).toBeGreaterThan(0.35); + expect(avg).toBeLessThan(0.65); + }); + + test("hypergeometric produces values in [0, min(K, n)]", () => { + const o = opts({ dist: "hypergeometric", popSize: 100, successes: 30, draws: 10, count: 500 }); + const values = [...generate(o)]; + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(10); + expect(Number.isInteger(v)).toBe(true); + } + }); + + test("hypergeometric K=0 always returns 0", () => { + const o = opts({ dist: "hypergeometric", popSize: 100, successes: 0, draws: 10, count: 50 }); + for (const v of generate(o)) expect(v).toBe(0); + }); + + test("hypergeometric draws=0 always returns 0", () => { + const o = opts({ dist: "hypergeometric", popSize: 100, successes: 50, draws: 0, count: 50 }); + for (const v of generate(o)) expect(v).toBe(0); + }); + + test("generator yields exactly count values", () => { + for (const count of [1, 5, 100]) { + const o = opts({ dist: "uniform", count }); + expect([...generate(o)].length).toBe(count); + } + }); + + // validateOptions defense-in-depth tests + test("throws on count < 1", () => { + const o = opts({ dist: "uniform", count: 0 }); + expect(() => [...generate(o)]).toThrow("invalid count"); + }); + + test("throws on negative decimals", () => { + const o = opts({ dist: "uniform", decimals: -1 }); + expect(() => [...generate(o)]).toThrow("decimals must be 0–100"); + }); + + test("throws on decimals > 100", () => { + const o = opts({ dist: "uniform", decimals: 101 }); + expect(() => [...generate(o)]).toThrow("decimals must be 0–100"); + }); + + test("throws on min > max", () => { + const o = opts({ dist: "uniform", min: 10, max: 5 }); + expect(() => [...generate(o)]).toThrow("min"); + }); + + test("throws on stddev <= 0", () => { + const o = opts({ dist: "normal", stddev: 0 }); + expect(() => [...generate(o)]).toThrow("stddev"); + }); + + test("throws on prob out of range", () => { + const o = opts({ dist: "binomial", prob: 1.5 }); + expect(() => [...generate(o)]).toThrow("prob"); + }); + + test("throws on lambda <= 0 for poisson", () => { + const o = opts({ dist: "poisson", lambda: 0 }); + expect(() => [...generate(o)]).toThrow("lambda"); + }); + + test("throws on lambda <= 0 for exponential", () => { + const o = opts({ dist: "exponential", lambda: -1 }); + expect(() => [...generate(o)]).toThrow("lambda"); + }); + + test("throws on hypergeometric K > N", () => { + const o = opts({ dist: "hypergeometric", popSize: 100, successes: 150, draws: 10 }); + expect(() => [...generate(o)]).toThrow("K"); + }); +}); diff --git a/src/__tests__/stdin.test.ts b/src/__tests__/stdin.test.ts new file mode 100644 index 0000000..be881fd --- /dev/null +++ b/src/__tests__/stdin.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from "bun:test"; +import { parseStdinNumbers } from "../stdin"; + +describe("parseStdinNumbers", () => { + test("empty string returns []", () => { + expect(parseStdinNumbers("")).toEqual([]); + }); + + test("whitespace-only returns []", () => { + expect(parseStdinNumbers(" \n\t ")).toEqual([]); + }); + + test("single number", () => { + expect(parseStdinNumbers("42")).toEqual([42]); + }); + + test("multiple numbers", () => { + expect(parseStdinNumbers("5 2")).toEqual([5, 2]); + }); + + test("numbers separated by newlines", () => { + expect(parseStdinNumbers("5\n2\n3")).toEqual([5, 2, 3]); + }); + + test("numbers separated by tabs and multiple spaces", () => { + expect(parseStdinNumbers("5\t2 3")).toEqual([5, 2, 3]); + }); + + test("leading and trailing whitespace is trimmed", () => { + expect(parseStdinNumbers(" 10 20 \n")).toEqual([10, 20]); + }); + + test("negative numbers", () => { + expect(parseStdinNumbers("-1 -0.5")).toEqual([-1, -0.5]); + }); + + test("float numbers", () => { + expect(parseStdinNumbers("3.14 2.718")).toEqual([3.14, 2.718]); + }); + + test("non-numeric input returns []", () => { + expect(parseStdinNumbers("abc")).toEqual([]); + }); + + test("mixed numeric and non-numeric returns []", () => { + expect(parseStdinNumbers("5 abc 10")).toEqual([]); + }); + + test("empty string after trim returns []", () => { + expect(parseStdinNumbers(" ")).toEqual([]); + }); +});