sdlc-hub/web/src/board/storage.test.ts

157 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> }[]) {
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([]);
});
});