import { readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { BoardSnapshot } from "../api/types.ts"; import { canon } from "../test/canon.ts"; import { computeVisible } from "./analysis/visible.ts"; import { dictsFromSettings } from "./dicts.ts"; import { exportFileName, exportJson } from "./exportJson.ts"; import settings from "./__fixtures__/settings.json"; import storageCases from "./__fixtures__/storage.cases.json"; import { computeLayout, measuredHeight } from "./layout/layout.ts"; import { fromHub } from "./model/fromHub.ts"; import { fromJson } from "./model/fromJson.ts"; import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, setStoreProject, toggleLane, writeFlag } from "./storage.ts"; import type { BoardModel, JsonImport } from "./types.ts"; import { noFilters } from "./types.ts"; const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); const { dicts } = dictsFromSettings(settings); const NOW = "2026-10-14T12:00:00.000Z"; const dump = () => Object.fromEntries(Object.keys(localStorage).map((k) => [k, localStorage.getItem(k)])); describe("хранилище = links.html@3931a88", () => { beforeEach(() => localStorage.clear()); afterEach(() => vi.restoreAllMocks()); const expected = JSON.parse(fx("storage.expected.json")); for (const c of storageCases as unknown as { name: string; op: string; before: Record }[]) { it(`миграция: ${c.name}`, () => { Object.entries(c.before).forEach(([k, v]) => localStorage.setItem(k, v)); if (c.op === "startup") migrateMode(); migrateStore(); expect(dump()).toEqual(expected[c.name]); }); } it("импорт JSON старой страницы: хранилище и фильтры", () => { const g = JSON.parse(fx("old-board.expected.json")); const patch = applyJsonSettings(fromJson(fx("old-board.json"), "old-board.json", dicts)); expect(dump()).toEqual(g.storage); const f = { ...noFilters(), ...patch }; expect(canon({ neighbors: f.neighbors, onlyLinked: f.onlyLinked, onlyHand: f.onlyHand, crit: f.crit, byDate: f.byDate, hideKind: f.hideKind, hideEdge: f.hideEdge })).toEqual(g.S); }); it("экспорт JSON после раскладки по умолчанию", () => { const g = JSON.parse(fx("hub-basic.expected.json")); const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); const vis = computeVisible(model, noFilters(), [], "2026-10-14"); const lay = computeLayout(vis, { byDate: false, colWidths: {}, saved: null, today: "2026-10-14", measure: () => ({ h: measuredHeight(117), other: 79 }) }); saveLayout(lay.rel, lay.colBounds, false); expect(canon(exportJson(model, vis, noFilters(), NOW))).toEqual(g.export); }); it("флаги и ширины колонок читаются обратно", () => { writeFlag("neighbors", true); writeFlag("snap", false); expect(readPrefs().filters.neighbors).toBe(true); expect(readPrefs().snap).toBe(false); setColWidth("2026-10-20", 400); expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({ "2026-10-20": 400 }); setColWidth("2026-10-20", null); expect(JSON.parse(localStorage.getItem("graph-colw:board")!)).toEqual({}); }); it("закрытое хранилище: чтение даёт умолчания, запись молча пропускается", () => { vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); vi.spyOn(Storage.prototype, "key").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); expect(() => { migrateMode(); migrateStore(); writeFlag("crit", true); }).not.toThrow(); expect(readPrefs().snap).toBe(true); }); it("раскладка неверной формы отбрасывается при чтении", () => { localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-1": null, "AURORA-2": { x: "1" }, "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } })); expect(savedLayout(false)).toEqual({ "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } }); localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-9003": { x: 30, ry: 50 } })); expect(savedLayout(false)).toEqual({ "AURORA-9003": { x: 30, ry: 50 } }); // M-1: без w запись принимается (w и h проверяет computeLayout) localStorage.setItem("graph-pos:board", "[1]"); expect(savedLayout(false)).toBeNull(); }); }); describe("операции хранилища", () => { beforeEach(() => localStorage.clear()); it("colWidths пропускает только конечные числа (T9.1)", () => { localStorage.setItem("graph-colw:board", JSON.stringify({ "2026-10-20": 400, "2026-10-21": "x", "2026-10-22": null })); expect(colWidths()).toEqual({ "2026-10-20": 400 }); }); it("toggleLane сворачивает и разворачивает", () => { toggleLane("Гудвилл"); expect(foldedSet()).toEqual(new Set(["Гудвилл"])); toggleLane("Гудвилл"); expect(foldedSet()).toEqual(new Set()); }); it("moveLane переставляет и сохраняет имена вне текущей выборки", () => { localStorage.setItem("graph-lanes:board", JSON.stringify(["A", "B", "C", "Скрытая"])); expect(moveLane(["A", "B", "C"], "A", 2)).toBe(true); expect(laneOrder()).toEqual(["B", "C", "A", "Скрытая"]); expect(moveLane(["A", "B", "C"], "A", 5)).toBe(false); expect(moveLane(["A", "B", "C"], "нет", 0)).toBe(false); }); it("resetLayout: обычный режим не трогает «по датам», режим дат чистит и ширины колонок", () => { for (const k of ["pos", "posdate", "lanes", "fold", "colw"]) localStorage.setItem(`graph-${k}:board`, "{}"); resetLayout(false); expect(Object.keys(localStorage).sort()).toEqual(["graph-colw:board", "graph-posdate:board"]); resetLayout(true); expect(Object.keys(localStorage)).toEqual([]); }); it("exportFileName: имя файла без .csv и недопустимых символов, дата из now", () => { expect(exportFileName({ fileName: "Jira (1).csv" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-Jira_1_-2026-10-14.json"); expect(exportFileName({ fileName: "" } as BoardModel, "2026-10-14T12:00:00Z")).toBe("links-board-2026-10-14.json"); }); it("applyJsonSettings: mode=all — пустой выбор спринтов", () => { applyJsonSettings({ layout: {}, layoutByDate: {}, laneOrder: [], collapsedLanes: null, dateColumns: null, filters: { mode: "all", sprints: ["S 2"] } } as unknown as JsonImport); expect(localStorage.getItem("graph-sprint:board")).toBe("[]"); }); it("закрытое хранилище: чтение — значения по умолчанию, запись и удаление без исключений", () => { const boom = () => { throw new DOMException("closed", "SecurityError"); }; vi.spyOn(Storage.prototype, "getItem").mockImplementation(boom); vi.spyOn(Storage.prototype, "setItem").mockImplementation(boom); vi.spyOn(Storage.prototype, "removeItem").mockImplementation(boom); expect(laneOrder()).toEqual([]); expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow(); }); }); describe("ключи по проектам", () => { beforeEach(() => localStorage.clear()); afterEach(() => setStoreProject(null)); it("AWPCA — прежние ключи", () => { setStoreProject("AWPCA"); writeFlag("crit", true); toggleLane("Эпик 1"); expect(localStorage.getItem("graph-crit")).toBe("1"); expect(localStorage.getItem("graph-fold:board")).toBe('["Эпик 1"]'); }); it("другой проект — ключи с суффиксом, AWPCA не тронут", () => { localStorage.setItem("graph-pos:board", '{"AURORA-1":{"x":1,"ry":2}}'); setStoreProject("STOCKS_DRRK"); writeFlag("crit", true); toggleLane("Эпик 1"); setColWidth("2026-10-20", 400); saveLayout({ "AURORA-2": { x: 10, ry: 20, w: 200, h: 80, auto: false } }, {}, false); applyJsonSettings({ layout: { "AURORA-3": { x: 1, ry: 1 } }, layoutByDate: {}, laneOrder: ["A"], filters: { neighbors: true } } as unknown as JsonImport); expect(localStorage.getItem("graph-crit@STOCKS_DRRK")).toBe("1"); expect(localStorage.getItem("graph-fold:board@STOCKS_DRRK")).toBe('["Эпик 1"]'); expect(localStorage.getItem("graph-lanes:board@STOCKS_DRRK")).toBe('["A"]'); expect(localStorage.getItem("graph-neighbors@STOCKS_DRRK")).toBe("1"); expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-1":{"x":1,"ry":2}}'); expect(localStorage.getItem("graph-crit")).toBeNull(); expect(laneOrder()).toEqual(["A"]); setStoreProject("AWPCA"); expect(laneOrder()).toEqual([]); }); });