Skip to content

Commit 93a5770

Browse files
jziggasterragon-labs[bot]
andauthored
Improve dice notation validation and error handling in CLI (#8)
* feat(cli): add robust error handling for invalid dice notation input - ParseDiceNotation now validates each part of the input string and collects warnings for: - Non-numeric dice counts - Non-integer dice counts (decimals, commas) - Missing dice color - Unrecognized dice colors - Warnings are printed to stderr to inform users of invalid parts without stopping execution. - The main CLI function checks if any valid dice were parsed; if none, it prints an error message and exits with code 1. - Extensive tests added to cover various invalid input scenarios and ensure warnings and errors are handled correctly. - package-lock.json updated to include CLI binary and node engine requirement. This improves user experience by providing clear feedback on input errors and preventing silent failures or confusing results. Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com> * test(cli): refactor console.error mocks and improve error message checks - Changed console.error mock to be recreated fresh in beforeEach and restored in afterEach - Updated error message assertions to check for specific messages in multiple calls - Improved clarity and reliability of CLI error handling tests Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com> --------- Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
1 parent 5195a1e commit 93a5770

File tree

3 files changed

+285
-2
lines changed

3 files changed

+285
-2
lines changed

package-lock.json

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,41 @@ export function parseDiceNotation(input: string): DicePool {
1515
forceDice: 0,
1616
};
1717

18-
const parts = input.toLowerCase().trim().split(" ");
18+
const warnings: string[] = [];
19+
const parts = input
20+
.toLowerCase()
21+
.trim()
22+
.split(" ")
23+
.filter((p) => p.length > 0);
1924

2025
for (const part of parts) {
2126
const count = parseInt(part);
27+
28+
// Check if parseInt returned NaN
29+
if (isNaN(count)) {
30+
warnings.push(`Invalid dice notation: "${part}" - number not found`);
31+
continue;
32+
}
33+
34+
// Check for non-integer values
35+
if (part.includes(".") || part.includes(",")) {
36+
warnings.push(
37+
`Invalid dice notation: "${part}" - dice count must be a whole number`,
38+
);
39+
continue;
40+
}
41+
2242
const color = part.slice(String(count).length).toLowerCase();
2343

44+
// Skip if no color specified
45+
if (!color) {
46+
warnings.push(
47+
`Invalid dice notation: "${part}" - no dice color specified`,
48+
);
49+
continue;
50+
}
51+
52+
let recognized = true;
2453
switch (color) {
2554
// y/pro = Yellow / Proficiency
2655
case "y":
@@ -77,9 +106,17 @@ export function parseDiceNotation(input: string): DicePool {
77106
case "f":
78107
pool.forceDice = count;
79108
break;
109+
default:
110+
recognized = false;
111+
warnings.push(`Invalid dice color: "${color}" in "${part}"`);
80112
}
81113
}
82114

115+
// Print warnings to stderr
116+
if (warnings.length > 0) {
117+
warnings.forEach((warning) => console.error(`Warning: ${warning}`));
118+
}
119+
83120
return pool;
84121
}
85122

@@ -133,6 +170,18 @@ export const main = () => {
133170
}
134171

135172
const pool = parseDiceNotation(diceNotation);
173+
174+
// Check if the pool is empty (all zeros) which might indicate invalid input
175+
const hasAnyDice = Object.values(pool).some((count) => count > 0);
176+
if (!hasAnyDice) {
177+
console.error("\nError: No valid dice found in the notation.");
178+
console.error(
179+
"Please check your input and ensure it follows the format: <count><color>",
180+
);
181+
console.error("Example: '2y 1g' for 2 yellow and 1 green dice\n");
182+
process.exit(1);
183+
}
184+
136185
const result = roll(pool, { hints: showHints });
137186
console.log(formatResult(result));
138187
};

tests/cli.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,16 @@ describe("CLI Functions", () => {
294294
});
295295

296296
describe("Error handling", () => {
297+
let consoleErrorSpy: jest.SpyInstance;
298+
299+
beforeEach(() => {
300+
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
301+
});
302+
303+
afterEach(() => {
304+
consoleErrorSpy.mockRestore();
305+
});
306+
297307
test("handles invalid dice notation", () => {
298308
expect(parseDiceNotation("invalid")).toEqual({
299309
boostDice: 0,
@@ -304,7 +314,11 @@ describe("Error handling", () => {
304314
challengeDice: 0,
305315
forceDice: 0,
306316
});
317+
expect(consoleErrorSpy).toHaveBeenCalledWith(
318+
'Warning: Invalid dice notation: "invalid" - number not found',
319+
);
307320
});
321+
308322
test("handles malformed dice numbers", () => {
309323
expect(parseDiceNotation("ayg")).toEqual({
310324
boostDice: 0,
@@ -315,6 +329,192 @@ describe("Error handling", () => {
315329
challengeDice: 0,
316330
forceDice: 0,
317331
});
332+
expect(consoleErrorSpy).toHaveBeenCalledWith(
333+
'Warning: Invalid dice notation: "ayg" - number not found',
334+
);
335+
});
336+
337+
test("handles invalid numbers like 'abc'", () => {
338+
expect(parseDiceNotation("abc")).toEqual({
339+
boostDice: 0,
340+
abilityDice: 0,
341+
proficiencyDice: 0,
342+
setBackDice: 0,
343+
difficultyDice: 0,
344+
challengeDice: 0,
345+
forceDice: 0,
346+
});
347+
expect(consoleErrorSpy).toHaveBeenCalledWith(
348+
'Warning: Invalid dice notation: "abc" - number not found',
349+
);
350+
});
351+
352+
test("handles decimal numbers like '2.5g'", () => {
353+
expect(parseDiceNotation("2.5g")).toEqual({
354+
boostDice: 0,
355+
abilityDice: 0,
356+
proficiencyDice: 0,
357+
setBackDice: 0,
358+
difficultyDice: 0,
359+
challengeDice: 0,
360+
forceDice: 0,
361+
});
362+
expect(consoleErrorSpy).toHaveBeenCalledWith(
363+
'Warning: Invalid dice notation: "2.5g" - dice count must be a whole number',
364+
);
365+
});
366+
367+
test("handles comma numbers like '2,5g'", () => {
368+
expect(parseDiceNotation("2,5g")).toEqual({
369+
boostDice: 0,
370+
abilityDice: 0,
371+
proficiencyDice: 0,
372+
setBackDice: 0,
373+
difficultyDice: 0,
374+
challengeDice: 0,
375+
forceDice: 0,
376+
});
377+
expect(consoleErrorSpy).toHaveBeenCalledWith(
378+
'Warning: Invalid dice notation: "2,5g" - dice count must be a whole number',
379+
);
380+
});
381+
382+
test("handles invalid dice colors like '2x' or '3zz'", () => {
383+
expect(parseDiceNotation("2x 3zz")).toEqual({
384+
boostDice: 0,
385+
abilityDice: 0,
386+
proficiencyDice: 0,
387+
setBackDice: 0,
388+
difficultyDice: 0,
389+
challengeDice: 0,
390+
forceDice: 0,
391+
});
392+
expect(consoleErrorSpy).toHaveBeenCalledWith(
393+
'Warning: Invalid dice color: "x" in "2x"',
394+
);
395+
expect(consoleErrorSpy).toHaveBeenCalledWith(
396+
'Warning: Invalid dice color: "zz" in "3zz"',
397+
);
398+
});
399+
400+
test("handles numbers without dice color like '5'", () => {
401+
expect(parseDiceNotation("5")).toEqual({
402+
boostDice: 0,
403+
abilityDice: 0,
404+
proficiencyDice: 0,
405+
setBackDice: 0,
406+
difficultyDice: 0,
407+
challengeDice: 0,
408+
forceDice: 0,
409+
});
410+
expect(consoleErrorSpy).toHaveBeenCalledWith(
411+
'Warning: Invalid dice notation: "5" - no dice color specified',
412+
);
413+
});
414+
415+
test("handles mixed valid and invalid notation", () => {
416+
expect(parseDiceNotation("2g invalid 1p 3.5b 1r 2x")).toEqual({
417+
boostDice: 0,
418+
abilityDice: 2,
419+
proficiencyDice: 0,
420+
setBackDice: 0,
421+
difficultyDice: 1,
422+
challengeDice: 1,
423+
forceDice: 0,
424+
});
425+
expect(consoleErrorSpy).toHaveBeenCalledWith(
426+
'Warning: Invalid dice notation: "invalid" - number not found',
427+
);
428+
expect(consoleErrorSpy).toHaveBeenCalledWith(
429+
'Warning: Invalid dice notation: "3.5b" - dice count must be a whole number',
430+
);
431+
expect(consoleErrorSpy).toHaveBeenCalledWith(
432+
'Warning: Invalid dice color: "x" in "2x"',
433+
);
434+
});
435+
436+
test("handles whitespace-only input", () => {
437+
expect(parseDiceNotation(" ")).toEqual({
438+
boostDice: 0,
439+
abilityDice: 0,
440+
proficiencyDice: 0,
441+
setBackDice: 0,
442+
difficultyDice: 0,
443+
challengeDice: 0,
444+
forceDice: 0,
445+
});
446+
expect(consoleErrorSpy).not.toHaveBeenCalled();
447+
});
448+
449+
test("handles multiple spaces between valid dice", () => {
450+
expect(parseDiceNotation("1g 2p")).toEqual({
451+
boostDice: 0,
452+
abilityDice: 1,
453+
proficiencyDice: 0,
454+
setBackDice: 0,
455+
difficultyDice: 2,
456+
challengeDice: 0,
457+
forceDice: 0,
458+
});
459+
expect(consoleErrorSpy).not.toHaveBeenCalled();
460+
});
461+
462+
test("handles special characters in notation", () => {
463+
expect(parseDiceNotation("@#$% 1g !@#")).toEqual({
464+
boostDice: 0,
465+
abilityDice: 1,
466+
proficiencyDice: 0,
467+
setBackDice: 0,
468+
difficultyDice: 0,
469+
challengeDice: 0,
470+
forceDice: 0,
471+
});
472+
expect(consoleErrorSpy).toHaveBeenCalledWith(
473+
'Warning: Invalid dice notation: "@#$%" - number not found',
474+
);
475+
expect(consoleErrorSpy).toHaveBeenCalledWith(
476+
'Warning: Invalid dice notation: "!@#" - number not found',
477+
);
478+
});
479+
480+
test("handles negative numbers", () => {
481+
expect(parseDiceNotation("-2g")).toEqual({
482+
boostDice: 0,
483+
abilityDice: -2,
484+
proficiencyDice: 0,
485+
setBackDice: 0,
486+
difficultyDice: 0,
487+
challengeDice: 0,
488+
forceDice: 0,
489+
});
490+
// Negative numbers are technically parsed but may not make sense semantically
491+
expect(consoleErrorSpy).not.toHaveBeenCalled();
492+
});
493+
494+
test("handles zero dice counts", () => {
495+
expect(parseDiceNotation("0g 0p")).toEqual({
496+
boostDice: 0,
497+
abilityDice: 0,
498+
proficiencyDice: 0,
499+
setBackDice: 0,
500+
difficultyDice: 0,
501+
challengeDice: 0,
502+
forceDice: 0,
503+
});
504+
expect(consoleErrorSpy).not.toHaveBeenCalled();
505+
});
506+
507+
test("handles very large numbers", () => {
508+
expect(parseDiceNotation("999999g")).toEqual({
509+
boostDice: 0,
510+
abilityDice: 999999,
511+
proficiencyDice: 0,
512+
setBackDice: 0,
513+
difficultyDice: 0,
514+
challengeDice: 0,
515+
forceDice: 0,
516+
});
517+
expect(consoleErrorSpy).not.toHaveBeenCalled();
318518
});
319519
});
320520

@@ -323,6 +523,8 @@ describe("CLI main function", () => {
323523
const mockConsoleLog = jest
324524
.spyOn(console, "log")
325525
.mockImplementation(() => {});
526+
// Mock console.error - create a fresh spy for this test suite
527+
let mockConsoleError: jest.SpyInstance;
326528
// Mock process.exit
327529
const mockExit = jest.spyOn(process, "exit").mockImplementation((number) => {
328530
throw new Error("process.exit: " + number);
@@ -332,10 +534,14 @@ describe("CLI main function", () => {
332534

333535
beforeEach(() => {
334536
process.argv = ["node", "script.js"]; // Reset to default
537+
mockConsoleError = jest
538+
.spyOn(console, "error")
539+
.mockImplementation(() => {});
335540
});
336541

337542
afterEach(() => {
338543
mockConsoleLog.mockClear();
544+
mockConsoleError.mockRestore();
339545
mockExit.mockClear();
340546
});
341547

@@ -357,4 +563,27 @@ describe("CLI main function", () => {
357563
expect(() => main()).not.toThrow();
358564
expect(mockConsoleLog).toHaveBeenCalled();
359565
});
566+
567+
test("shows error message for completely invalid notation", () => {
568+
process.argv = ["node", "script.js", "invalid", "xyz"];
569+
expect(() => main()).toThrow("process.exit: 1");
570+
// Multiple console.error calls are made - check for the main error message
571+
const errorCalls = mockConsoleError.mock.calls.map((call) => call[0]);
572+
expect(errorCalls).toContain(
573+
"\nError: No valid dice found in the notation.",
574+
);
575+
});
576+
577+
test("processes mixed valid and invalid notation", () => {
578+
process.argv = ["node", "script.js", "1y", "invalid", "2g"];
579+
// This should still work since it has some valid dice
580+
expect(() => main()).not.toThrow();
581+
// Check that warning was logged for invalid notation
582+
const errorCalls = mockConsoleError.mock.calls.map((call) => call[0]);
583+
expect(errorCalls).toContain(
584+
'Warning: Invalid dice notation: "invalid" - number not found',
585+
);
586+
// Check that the result was still displayed
587+
expect(mockConsoleLog).toHaveBeenCalled();
588+
});
360589
});

0 commit comments

Comments
 (0)