Files
Randify.pro/src/lib/dice-engine.test.ts
T
emil 8f11c5af43 feat: add favorites panel to meal generator, remove redundant Another one button
- Add clickable favorites counter with expandable panel
- Render saved recipes list with remove button
- Remove duplicate 'Another one' button (Generate already covers it)
- Sync favorite button state when removing from panel
2026-05-13 00:25:09 +03:00

287 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import {
parseDiceNotation,
rollDice,
rollAdvantage,
buildNotation,
isAdvancedNotation,
} from "./dice-engine";
describe("parseDiceNotation", () => {
it('parses basic notation "d6"', () => {
const parsed = parseDiceNotation("d6");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(1);
expect(parsed!.sides).toBe(6);
expect(parsed!.modifier).toBe(0);
expect(parsed!.keepDrop).toBeNull();
expect(parsed!.explode.active).toBe(false);
expect(parsed!.reroll.active).toBe(false);
});
it('parses "2d20+3"', () => {
const parsed = parseDiceNotation("2d20+3");
expect(parsed).not.toBeNull();
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(3);
});
it('parses "3d8-1"', () => {
const parsed = parseDiceNotation("3d8-1");
expect(parsed!.count).toBe(3);
expect(parsed!.sides).toBe(8);
expect(parsed!.modifier).toBe(-1);
});
it('parses "4d6kh3"', () => {
const parsed = parseDiceNotation("4d6kh3");
expect(parsed!.keepDrop).toEqual({ type: "kh", count: 3 });
});
it('parses "5d6dl2"', () => {
const parsed = parseDiceNotation("5d6dl2");
expect(parsed!.keepDrop).toEqual({ type: "dl", count: 2 });
});
it('parses exploding "3d6!"', () => {
const parsed = parseDiceNotation("3d6!");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(false);
expect(parsed!.explode.threshold).toBe(6);
});
it('parses penetrating explode "2d10!p"', () => {
const parsed = parseDiceNotation("2d10!p");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.penetrating).toBe(true);
});
it('parses explode with custom threshold "2d6!>4"', () => {
const parsed = parseDiceNotation("2d6!>4");
expect(parsed!.explode.active).toBe(true);
expect(parsed!.explode.threshold).toBe(4);
});
it('parses reroll "2d6r1"', () => {
const parsed = parseDiceNotation("2d6r1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(false);
expect(parsed!.reroll.values).toContain(1);
});
it('parses reroll once "2d6ro1"', () => {
const parsed = parseDiceNotation("2d6ro1");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.once).toBe(true);
});
it('parses reroll less-than "2d6r<3"', () => {
const parsed = parseDiceNotation("2d6r<3");
expect(parsed!.reroll.active).toBe(true);
expect(parsed!.reroll.values).toContain(1);
expect(parsed!.reroll.values).toContain(2);
expect(parsed!.reroll.values).not.toContain(3);
});
it("ignores whitespace", () => {
const parsed = parseDiceNotation(" 2 d 20 + 5 ");
expect(parsed!.count).toBe(2);
expect(parsed!.sides).toBe(20);
expect(parsed!.modifier).toBe(5);
});
it("returns null for invalid notation", () => {
expect(parseDiceNotation("")).toBeNull();
expect(parseDiceNotation("abc")).toBeNull();
expect(parseDiceNotation("2d6+1+2")).toBeNull();
expect(parseDiceNotation("0d6")).toBeNull();
expect(parseDiceNotation("21d6")).toBeNull();
expect(parseDiceNotation("2d0")).toBeNull();
expect(parseDiceNotation("2d6kh0")).toBeNull();
expect(parseDiceNotation("2d6kh2")).toBeNull();
});
});
describe("rollDice", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("rolls basic dice", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("3d6")!;
const result = rollDice(parsed);
expect(result.dice).toHaveLength(3);
expect(result.kept).toHaveLength(3);
expect(result.dropped).toHaveLength(0);
expect(result.total).toBe(12); // each die: floor(0.5*6)+1 = 4, 3*4 = 12
});
it("applies modifier", () => {
mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const parsed = parseDiceNotation("1d20+5")!;
const result = rollDice(parsed);
expect(result.total).toBe(16); // 11 + 5
});
it("keeps highest rolls (kh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6kh2")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].dropped).toBe(true);
expect(result.total).toBe(10); // 6 + 4
});
it("drops highest rolls (dh)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 1
.mockReturnValueOnce(0.9) // 6
.mockReturnValueOnce(0.5); // 4
const parsed = parseDiceNotation("3d6dh1")!;
const result = rollDice(parsed);
expect(result.kept).toHaveLength(2);
expect(result.dropped).toHaveLength(1);
expect(result.dropped[0].value).toBe(6);
expect(result.total).toBe(5); // 1 + 4
});
it("handles exploding dice", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (max, triggers explode)
.mockReturnValueOnce(0.99) // explosion 1: 6 (triggers again)
.mockReturnValueOnce(0.1); // explosion 2: 1 (stops)
const parsed = parseDiceNotation("1d6!")!;
const result = rollDice(parsed);
expect(result.dice[0].exploded).toBe(true);
expect(result.dice[0].explosions).toEqual([6, 1]);
expect(result.total).toBe(13); // 6 + 6 + 1
});
it("handles penetrating explode", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.99) // first roll: 6 (explode)
.mockReturnValueOnce(0.99); // explosion: 6 -> penetrating -> 5, then 5 < 6 stops
const parsed = parseDiceNotation("1d6!p")!;
const result = rollDice(parsed);
expect(result.dice[0].explosions).toEqual([5]);
expect(result.total).toBe(11); // 6 + 5
});
it("handles reroll", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first roll: 1 (reroll)
.mockReturnValueOnce(0.5); // reroll: 4
const parsed = parseDiceNotation("1d6r1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(4);
});
it("handles reroll once (ro)", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.0) // first: 1 (reroll once)
.mockReturnValueOnce(0.0); // second: 1 (stays, no more rerolls)
const parsed = parseDiceNotation("1d6ro1")!;
const result = rollDice(parsed);
expect(result.dice[0].rerolledFrom).toBe(1);
expect(result.dice[0].value).toBe(1);
});
});
describe("rollAdvantage", () => {
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
mathRandomSpy?.mockRestore();
});
it("returns highest for advantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.1) // 3
.mockReturnValueOnce(0.9); // 19
const result = rollAdvantage(20, 0, true);
expect(result.total).toBe(19);
expect(result.advantageRolls).toEqual([3, 19]);
});
it("returns lowest for disadvantage", () => {
mathRandomSpy = vi
.spyOn(Math, "random")
.mockReturnValueOnce(0.9) // 19
.mockReturnValueOnce(0.1); // 3
const result = rollAdvantage(20, 2, false);
expect(result.total).toBe(5); // 3 + 2
expect(result.advantageRolls).toEqual([19, 3]);
});
});
describe("buildNotation", () => {
it("builds basic notation", () => {
expect(buildNotation(1, 6, 0)).toBe("1d6");
expect(buildNotation(2, 20, 0)).toBe("2d20");
});
it("adds positive modifier", () => {
expect(buildNotation(2, 6, 3)).toBe("2d6+3");
});
it("adds negative modifier", () => {
expect(buildNotation(2, 6, -2)).toBe("2d6-2");
});
});
describe("isAdvancedNotation", () => {
it("returns false for basic notation", () => {
expect(isAdvancedNotation("2d6")).toBe(false);
expect(isAdvancedNotation("1d20+5")).toBe(false);
expect(isAdvancedNotation("3d8-1")).toBe(false);
});
it("returns true for keep/drop", () => {
expect(isAdvancedNotation("4d6kh3")).toBe(true);
expect(isAdvancedNotation("5d6dl2")).toBe(true);
});
it("returns true for explode", () => {
expect(isAdvancedNotation("3d6!")).toBe(true);
expect(isAdvancedNotation("2d10!p")).toBe(true);
});
it("returns true for reroll", () => {
expect(isAdvancedNotation("2d6r1")).toBe(true);
expect(isAdvancedNotation("2d6ro1")).toBe(true);
expect(isAdvancedNotation("2d6r<3")).toBe(true);
});
});