import { describe, expect, mock, test } from "bun:test"; import type { TierlistResolvedConfig, TierlistStore } from "./tierlist"; import { getTierlistConfigFromStore, normalizeTierlistSnapshot, parseTierlistDate, resolveCharacterVersionIds, shouldSnapshotTierlist, syncTierlistSnapshotsFromStore, } from "./tierlist"; type Version = Awaited>; function version(overrides: Record = {}) { return { id: "list-1", name: "List 1", hidden: false, type: "main", image: null, disclaimer: null, deprecates: "not scheduled", from: "v3", order: 1, placements: {}, snapshot: null, ...overrides, } as NonNullable; } function config(overrides: Partial = {}) { return { type: { id: "main", name: "Main", image: null, order: 1, mode: "team" }, version: version(), tiers: [], columns: [], badges: [], chars: [], ...overrides, } as TierlistResolvedConfig; } function character(id: string, characterVersion = "v3") { return { id, name: id, version: characterVersion, stars: 5, vision: "electro", image: `${id}-image`, weapon: "sword", amber: id, order: 1, } as TierlistResolvedConfig["chars"][number]; } function store(overrides: Partial = {}) { return { findVersion: mock(async () => version()), listVersions: mock(async () => []), getType: mock(async () => ({ id: "main", name: "Main", image: null, order: 1, mode: "team", })), listCharacterVersions: mock(async () => [ { id: "v3", from: "v2" }, { id: "v2", from: "v1" }, { id: "v1", from: null }, ]), listCharacters: mock(async () => []), listTiers: mock(async () => []), listColumns: mock(async () => []), listBadges: mock(async () => []), saveSnapshot: mock(async () => {}), ...overrides, } as TierlistStore; } describe("tier-list character ancestry", () => { test("resolves the root and all ancestors in nearest-first order", () => { expect( resolveCharacterVersionIds( [ { id: "base", from: null }, { id: "middle", from: "base" }, { id: "current", from: "middle" }, { id: "unrelated", from: null }, ], "current", ), ).toEqual(["current", "middle", "base"]); }); test("keeps an unknown root and stops at the missing parent", () => { expect(resolveCharacterVersionIds([], "missing")).toEqual(["missing"]); expect( resolveCharacterVersionIds( [{ id: "current", from: "missing" }], "current", ), ).toEqual(["current", "missing"]); }); test.each([ [[{ id: "a", from: "a" }], "a"], [ [ { id: "a", from: "b" }, { id: "b", from: "c" }, { id: "c", from: "a" }, ], "a", ], ])("rejects ancestry cycles", (versions, root) => { expect(() => resolveCharacterVersionIds(versions, root)).toThrow( "Character version ancestry cycle detected", ); }); }); describe("tier-list snapshot rules", () => { test("normalizes embedded snapshots without mutating them", () => { const original = config({ version: version({ snapshot: { persisted: true } }), }); const normalized = normalizeTierlistSnapshot(original); expect(normalized.version.snapshot).toBeNull(); expect(original.version.snapshot).toEqual({ persisted: true }); expect(normalized).not.toBe(original); expect(normalized.version).not.toBe(original.version); }); test.each([ ["29/02/2024", true], ["29/02/2023", false], ["31/04/2024", false], ["00/01/2024", false], ["01/13/2024", false], ["1/01/2024", false], ["2024-01-01", false], ])("validates date %s", (value, valid) => { expect(Number.isFinite(parseTierlistDate(value))).toBe(valid); }); test("snapshots at the exact 30-day boundary but not before it", () => { const deprecated = parseTierlistDate("01/06/2024"); const candidate = config({ version: version({ deprecates: "01/06/2024" }), }); const month = 30 * 24 * 60 * 60 * 1000; expect( shouldSnapshotTierlist(candidate, deprecated + month - 1), ).toBeFalse(); expect(shouldSnapshotTierlist(candidate, deprecated + month)).toBeTrue(); }); test("snapshots only when every available character has a placement", () => { const chars = [character("a"), character("b")]; expect( shouldSnapshotTierlist( config({ chars, version: version({ placements: { dps: ["a#copy"], support: ["b"] } }), }), ), ).toBeTrue(); expect( shouldSnapshotTierlist( config({ chars, version: version({ placements: { dps: ["a"] } }) }), ), ).toBeFalse(); expect( shouldSnapshotTierlist( config({ chars: [], version: version({ placements: { dps: [] } }) }), ), ).toBeFalse(); }); test("an old valid date snapshots an incomplete list; malformed dates do not", () => { expect( shouldSnapshotTierlist( config({ version: version({ deprecates: "01/01/2000" }) }), ), ).toBeTrue(); expect( shouldSnapshotTierlist( config({ version: version({ deprecates: "31/02/2000" }) }), ), ).toBeFalse(); }); }); describe("getTierlistConfigFromStore", () => { test("returns null when the version or type does not exist", async () => { const noVersion = store({ findVersion: mock(async () => null) }); expect( await getTierlistConfigFromStore(noVersion, "main", "missing"), ).toBeNull(); expect(noVersion.listCharacterVersions).not.toHaveBeenCalled(); const noType = store({ getType: mock(async () => null) }); expect( await getTierlistConfigFromStore(noType, "main", "list-1"), ).toBeNull(); expect(noType.listCharacterVersions).not.toHaveBeenCalled(); }); test("returns a normalized persisted snapshot without rebuilding or saving", async () => { const persisted = config({ version: version({ snapshot: null }) }); const source = store({ findVersion: mock(async () => version({ snapshot: persisted })), }); const result = await getTierlistConfigFromStore(source, "main", "list-1"); expect(result?.version.snapshot).toBeNull(); expect(source.getType).not.toHaveBeenCalled(); expect(source.saveSnapshot).not.toHaveBeenCalled(); }); test("builds inherited characters, resolves badge tiers, and saves a complete list", async () => { const chars = [character("a", "v3"), character("b", "v1")]; const source = store({ findVersion: mock(async () => version({ placements: { dps: ["a", "b#alternate"] } }), ), listCharacters: mock(async () => chars), listTiers: mock(async () => [ { id: "s", name: "S", badges: ["carry"], image: null, order: 1 }, { id: "a", name: "A", badges: null, image: null, order: 2 }, ]), listBadges: mock(async () => [ { id: "carry", name: "Carry", image: null, order: 1, type: null }, ]), }); const result = await getTierlistConfigFromStore(source, "main", "list-1"); expect(source.listCharacters).toHaveBeenCalledWith(["v3", "v2", "v1"]); expect(result?.badges[0].tier).toEqual(["s"]); expect(result?.version.snapshot).toBeNull(); expect(source.saveSnapshot).toHaveBeenCalledTimes(1); expect(source.saveSnapshot).toHaveBeenCalledWith("list-1", result); }); test("does not save a current incomplete live list", async () => { const source = store({ listCharacters: mock(async () => [character("a")]), }); const result = await getTierlistConfigFromStore(source, "main", "list-1"); expect(result?.chars).toHaveLength(1); expect(source.saveSnapshot).not.toHaveBeenCalled(); }); }); describe("syncTierlistSnapshotsFromStore", () => { test("skips existing snapshots and saves every newly eligible version", async () => { const versions = [ version({ id: "saved", from: "saved", snapshot: { already: true }, order: 1, }), version({ id: "complete", from: "complete", placements: { dps: ["c"] }, order: 2, }), version({ id: "old", from: "old", deprecates: "01/01/2000", order: 3 }), version({ id: "current", from: "current", order: 4 }), ]; const source = store({ listVersions: mock(async () => versions), listCharacterVersions: mock(async () => versions.map(({ from }) => ({ id: from, from: null })), ), listCharacters: mock(async (ids) => ids[0] === "complete" || ids[0] === "current" ? [character(ids[0] === "complete" ? "c" : ids[0], ids[0])] : [], ), }); await syncTierlistSnapshotsFromStore(source, "main"); expect(source.listVersions).toHaveBeenCalledWith("main"); expect(source.saveSnapshot).toHaveBeenCalledTimes(2); expect( (source.saveSnapshot as ReturnType).mock.calls.map( ([id]) => id, ), ).toEqual(["complete", "old"]); expect(source.getType).toHaveBeenCalledTimes(3); }); });