|
1 | 1 | /** |
2 | | - * Checks for the turn budget. Run with: node budget.test.ts |
| 2 | + * The turn budget. |
3 | 3 | * |
4 | | - * The exhaustion test is the one backstop between a runaway loop and a very |
5 | | - * large bill, so it is pinned here rather than living inline in the event loop. |
| 4 | + * `isExhausted` is the one backstop between a runaway loop and a very large |
| 5 | + * bill, so it is pinned here rather than left inline in the event loop. |
6 | 6 | */ |
| 7 | +import { describe, test } from "node:test" |
| 8 | +import assert from "node:assert/strict" |
7 | 9 | import { |
8 | 10 | budgetLabel, |
9 | 11 | DEFAULT_BUDGET, |
10 | 12 | formatTurns, |
11 | 13 | isExhausted, |
12 | 14 | parseBudgetArgument, |
13 | 15 | readBudgetOption, |
14 | | - UNLIMITED_HINT, |
15 | 16 | UNLIMITED_CAVEAT, |
16 | | - type Budget, |
| 17 | + UNLIMITED_HINT, |
17 | 18 | } from "./budget.ts" |
18 | 19 |
|
19 | | -let failures = 0 |
| 20 | +describe("isExhausted", () => { |
| 21 | + test("is not spent at the start", () => { |
| 22 | + assert.equal(isExhausted(0, 20), false) |
| 23 | + }) |
| 24 | + |
| 25 | + test("is not spent one turn under the limit", () => { |
| 26 | + assert.equal(isExhausted(19, 20), false) |
| 27 | + }) |
| 28 | + |
| 29 | + test("is spent exactly at the limit", () => { |
| 30 | + assert.equal(isExhausted(20, 20), true) |
| 31 | + }) |
| 32 | + |
| 33 | + test("is spent past the limit", () => { |
| 34 | + assert.equal(isExhausted(21, 20), true) |
| 35 | + }) |
| 36 | + |
| 37 | + test("a limit of one is spent after one turn", () => { |
| 38 | + assert.equal(isExhausted(1, 1), true) |
| 39 | + }) |
| 40 | + |
| 41 | + for (const turns of [0, 20, 100_000]) { |
| 42 | + test(`an unlimited budget is never spent after ${turns} turns`, () => { |
| 43 | + assert.equal(isExhausted(turns, null), false) |
| 44 | + }) |
| 45 | + } |
| 46 | +}) |
| 47 | + |
| 48 | +describe("parseBudgetArgument", () => { |
| 49 | + const unlimited = { kind: "budget", budget: null } |
| 50 | + |
| 51 | + for (const word of ["unlimited", "unlim", "unbounded", "infinite", "inf", "none", "no-limit", "∞"]) { |
| 52 | + test(`reads "${word}" as unlimited`, () => { |
| 53 | + assert.deepEqual(parseBudgetArgument(word), unlimited) |
| 54 | + }) |
| 55 | + } |
| 56 | + |
| 57 | + test("ignores casing and padding", () => { |
| 58 | + assert.deepEqual(parseBudgetArgument(" UNLIMITED "), unlimited) |
| 59 | + assert.deepEqual(parseBudgetArgument(" ∞ "), unlimited) |
| 60 | + }) |
| 61 | + |
| 62 | + test("reads a whole number", () => { |
| 63 | + assert.deepEqual(parseBudgetArgument("40"), { kind: "budget", budget: 40 }) |
| 64 | + }) |
20 | 65 |
|
21 | | -function check(name: string, actual: unknown, expected: unknown) { |
22 | | - const a = JSON.stringify(actual) |
23 | | - const e = JSON.stringify(expected) |
24 | | - if (a === e) { |
25 | | - console.log(` ok ${name}`) |
26 | | - } else { |
27 | | - failures++ |
28 | | - console.log(` FAIL ${name}\n expected ${e}\n actual ${a}`) |
| 66 | + test("reads default as a clear", () => { |
| 67 | + assert.deepEqual(parseBudgetArgument("default"), { kind: "default" }) |
| 68 | + }) |
| 69 | + |
| 70 | + for (const [input, value] of [ |
| 71 | + ["0", "0"], |
| 72 | + ["-5", "-5"], |
| 73 | + ["2.5", "2.5"], |
| 74 | + ["lots", "lots"], |
| 75 | + [" ", ""], |
| 76 | + ]) { |
| 77 | + test(`rejects ${JSON.stringify(input)}`, () => { |
| 78 | + assert.deepEqual(parseBudgetArgument(input), { kind: "invalid", value }) |
| 79 | + }) |
29 | 80 | } |
30 | | -} |
31 | | - |
32 | | -console.log("isExhausted — the gate that stops a runaway") |
33 | | -check("not spent at the start", isExhausted(0, 20), false) |
34 | | -check("not spent one under", isExhausted(19, 20), false) |
35 | | -check("spent exactly at the limit", isExhausted(20, 20), true) |
36 | | -check("spent past the limit", isExhausted(21, 20), true) |
37 | | -check("a limit of 1 is spent after one turn", isExhausted(1, 1), true) |
38 | | -check("unlimited is never spent at zero", isExhausted(0, null), false) |
39 | | -check("unlimited is never spent at 20", isExhausted(20, null), false) |
40 | | -check("unlimited is never spent at 100000", isExhausted(100000, null), false) |
41 | | - |
42 | | -console.log("parseBudgetArgument — /goal budget …") |
43 | | -check("unlimited", parseBudgetArgument("unlimited"), { kind: "budget", budget: null }) |
44 | | -check("Unlimited with odd casing", parseBudgetArgument(" UNLIMITED "), { kind: "budget", budget: null }) |
45 | | -check("infinite", parseBudgetArgument("infinite"), { kind: "budget", budget: null }) |
46 | | -check("none", parseBudgetArgument("none"), { kind: "budget", budget: null }) |
47 | | -check("inf is a short alias", parseBudgetArgument("inf"), { kind: "budget", budget: null }) |
48 | | -check("unlim is a short alias", parseBudgetArgument("unlim"), { kind: "budget", budget: null }) |
49 | | -check("the infinity sign is an alias", parseBudgetArgument("\u221e"), { kind: "budget", budget: null }) |
50 | | -check("infinity sign with padding", parseBudgetArgument(" \u221e "), { kind: "budget", budget: null }) |
51 | | -check("unlim is not read as a count", parseBudgetArgument("unlim").kind, "budget") |
52 | | -check("a count", parseBudgetArgument("40"), { kind: "budget", budget: 40 }) |
53 | | -check("default", parseBudgetArgument("default"), { kind: "default" }) |
54 | | -check("zero is not a budget", parseBudgetArgument("0"), { kind: "invalid", value: "0" }) |
55 | | -check("a negative count is invalid", parseBudgetArgument("-5"), { kind: "invalid", value: "-5" }) |
56 | | -check("a decimal is invalid", parseBudgetArgument("2.5"), { kind: "invalid", value: "2.5" }) |
57 | | -check("nonsense is invalid", parseBudgetArgument("lots"), { kind: "invalid", value: "lots" }) |
58 | | -check("empty is invalid", parseBudgetArgument(" "), { kind: "invalid", value: "" }) |
59 | | - |
60 | | -console.log("readBudgetOption — the maxTurns plugin option") |
61 | | -check("absent uses the default", readBudgetOption(undefined), { budget: DEFAULT_BUDGET }) |
62 | | -check("a number is used as-is", readBudgetOption(40), { budget: 40 }) |
63 | | -check("null means unlimited", readBudgetOption(null), { budget: null }) |
64 | | -check("the word unlimited means unlimited", readBudgetOption("unlimited"), { budget: null }) |
65 | | -check("zero is rejected with a warning", readBudgetOption(0), { |
66 | | - budget: DEFAULT_BUDGET, |
67 | | - warning: |
68 | | - "maxTurns must be a whole number of at least 1, or \"unlimited\"; got 0. Using 20.", |
69 | 81 | }) |
70 | | -check("a negative is rejected with a warning", readBudgetOption(-3).budget, DEFAULT_BUDGET) |
71 | | -check("a decimal is rejected with a warning", readBudgetOption(1.5).budget, DEFAULT_BUDGET) |
72 | | -check("garbage is rejected with a warning", readBudgetOption("lots").budget, DEFAULT_BUDGET) |
73 | | -check("a warning is actually present", typeof readBudgetOption(0).warning, "string") |
74 | | - |
75 | | -console.log("display") |
76 | | -check("label for a count", budgetLabel(20), "20") |
77 | | -check("label when unlimited", budgetLabel(null), "unlimited") |
78 | | -check("counter with a count", formatTurns(3, 20), "3/20") |
79 | | -check("counter when unlimited", formatTurns(3, null), "3/∞") |
80 | | -check("counter at zero when unlimited", formatTurns(0, null), "0/∞") |
81 | | - |
82 | | -console.log("misc") |
83 | | -check("the caveat names what still stops it", /judge|stall|repeat/i.test(UNLIMITED_CAVEAT), true) |
84 | | -check("the hint advertises a short alias", /\binf\b/.test(UNLIMITED_HINT), true) |
85 | | -check("the caveat mentions no ceiling", /ceiling/i.test(UNLIMITED_CAVEAT), true) |
86 | | - |
87 | | -console.log(failures === 0 ? "\nall passed" : `\n${failures} FAILED`) |
88 | | -process.exit(failures === 0 ? 0 : 1) |
| 82 | + |
| 83 | +describe("readBudgetOption", () => { |
| 84 | + test("falls back to the default when absent", () => { |
| 85 | + assert.deepEqual(readBudgetOption(undefined), { budget: DEFAULT_BUDGET }) |
| 86 | + }) |
| 87 | + |
| 88 | + test("uses a number as given", () => { |
| 89 | + assert.deepEqual(readBudgetOption(40), { budget: 40 }) |
| 90 | + }) |
| 91 | + |
| 92 | + for (const input of [null, "unlimited", "inf", "none", "∞"]) { |
| 93 | + test(`reads ${JSON.stringify(input)} as unlimited`, () => { |
| 94 | + assert.deepEqual(readBudgetOption(input), { budget: null }) |
| 95 | + }) |
| 96 | + } |
| 97 | + |
| 98 | + for (const input of [0, -3, 1.5, "lots"]) { |
| 99 | + test(`rejects ${JSON.stringify(input)} and warns`, () => { |
| 100 | + const result = readBudgetOption(input) |
| 101 | + assert.equal(result.budget, DEFAULT_BUDGET) |
| 102 | + assert.equal(typeof result.warning, "string") |
| 103 | + assert.match(result.warning ?? "", /maxTurns must be a whole number/) |
| 104 | + }) |
| 105 | + } |
| 106 | +}) |
| 107 | + |
| 108 | +describe("display", () => { |
| 109 | + test("labels a finite budget as a count", () => { |
| 110 | + assert.equal(budgetLabel(20), "20") |
| 111 | + }) |
| 112 | + |
| 113 | + test("labels an unlimited budget in words", () => { |
| 114 | + assert.equal(budgetLabel(null), "unlimited") |
| 115 | + }) |
| 116 | + |
| 117 | + test("formats the counter against a limit", () => { |
| 118 | + assert.equal(formatTurns(3, 20), "3/20") |
| 119 | + }) |
| 120 | + |
| 121 | + test("formats the counter against no limit", () => { |
| 122 | + assert.equal(formatTurns(3, null), "3/∞") |
| 123 | + assert.equal(formatTurns(0, null), "0/∞") |
| 124 | + }) |
| 125 | +}) |
| 126 | + |
| 127 | +describe("wording", () => { |
| 128 | + test("the unlimited caveat says what still stops the loop", () => { |
| 129 | + assert.match(UNLIMITED_CAVEAT, /judge/i) |
| 130 | + assert.match(UNLIMITED_CAVEAT, /stall|repetition/i) |
| 131 | + }) |
| 132 | + |
| 133 | + test("the unlimited caveat admits there is no ceiling", () => { |
| 134 | + assert.match(UNLIMITED_CAVEAT, /ceiling/i) |
| 135 | + }) |
| 136 | + |
| 137 | + test("the usage hint advertises the short spelling", () => { |
| 138 | + assert.match(UNLIMITED_HINT, /\binf\b/) |
| 139 | + }) |
| 140 | +}) |
0 commit comments