diff --git a/web/src/board/__fixtures__/hub-basic.expected.json b/web/src/board/__fixtures__/hub-basic.expected.json index 4c33bc1..f59f2fa 100644 --- a/web/src/board/__fixtures__/hub-basic.expected.json +++ b/web/src/board/__fixtures__/hub-basic.expected.json @@ -6312,6 +6312,788 @@ } } }, + "saved-short": { + "emptyReason": null, + "layout": { + "baseW": 1208, + "board": { + "height": "1124px", + "width": "1208px" + }, + "clamp": { + "AURORA-9001": "2", + "AURORA-9002": "3", + "AURORA-9003": "unset", + "AURORA-9004": "6", + "AURORA-9005": "unset", + "AURORA-9006": "unset", + "AURORA-9007": "unset", + "AURORA-9008": "12", + "AURORA-9009": "unset" + }, + "colBounds": {}, + "dateCols": [], + "dateLabels": [ + { + "className": "dlabel none", + "innerHTML": "без срока1", + "left": "20px", + "title": "плановая дата не задана" + }, + { + "className": "dlabel past", + "innerHTML": "10.101", + "left": "316px", + "title": "срок прошёл" + }, + { + "className": "dlabel past", + "innerHTML": "12.101", + "left": "612px", + "title": "срок прошёл" + }, + { + "className": "dlabel", + "innerHTML": "16.101", + "left": "908px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "18.101", + "left": "1204px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "20.103", + "left": "1500px", + "title": "плановая дата окончания" + }, + { + "className": "dlabel", + "innerHTML": "25.101", + "left": "1900px", + "title": "плановая дата окончания" + } + ], + "edges": [ + { + "crit": false, + "d": "M270 372 L291 372 Q295 372 295 376 L295 376 Q295 380 299 380 L320 380", + "dst": "AURORA-9002", + "hand": true, + "src": "AURORA-9001", + "type": "блокирует начало" + }, + { + "crit": false, + "d": "M264 498 L288 498 Q298 498 298 488 L298 382 Q298 372 288 372 L270 372", + "dst": "AURORA-9001", + "hand": true, + "src": "AURORA-9003", + "type": "блокирует начало" + }, + { + "crit": false, + "d": "M560 380 L726 380 Q736 380 736 370 L736 110 Q736 100 746 100 L912 100", + "dst": "AURORA-9005", + "hand": false, + "src": "AURORA-9002", + "type": "блокирует завершение" + }, + { + "crit": false, + "d": "M270 150 L285 150 Q295 150 295 160 L295 370 Q295 380 305 380 L320 380", + "dst": "AURORA-9002", + "hand": true, + "src": "AURORA-9004", + "type": "блокирует начало" + }, + { + "crit": false, + "d": "M150 432 L150 516 Q150 526 160 526 L160 526 Q170 526 170 536 L170 620", + "dst": "AURORA-9008", + "hand": false, + "src": "AURORA-9001", + "type": "связанные" + }, + { + "crit": false, + "d": "M264 362 L320 362", + "dst": "AURORA-9009", + "hand": true, + "src": "AURORA-9007", + "type": "блокирует начало" + } + ], + "laneOf": { + "AURORA-9001": 1, + "AURORA-9002": 1, + "AURORA-9003": 1, + "AURORA-9004": 0, + "AURORA-9005": 0, + "AURORA-9006": 3, + "AURORA-9007": 1, + "AURORA-9008": 2, + "AURORA-9009": 1 + }, + "lanes": [ + { + "folded": false, + "h": 262, + "minH": 144, + "name": "Гудвилл", + "y": 0 + }, + { + "folded": false, + "h": 308, + "minH": 144, + "name": "Тестовый эпик", + "y": 262 + }, + { + "folded": false, + "h": 382, + "minH": 144, + "name": "AURORA-9999", + "y": 570 + }, + { + "folded": false, + "h": 172, + "minH": 144, + "name": "Без эпика", + "y": 952 + } + ], + "pos": { + "AURORA-9001": { + "h": 120, + "w": 240, + "x": 30, + "y": 312 + }, + "AURORA-9002": { + "h": 136, + "w": 240, + "x": 320, + "y": 312 + }, + "AURORA-9003": { + "h": 120, + "w": 240, + "x": 24, + "y": 438 + }, + "AURORA-9004": { + "h": 200, + "w": 240, + "x": 30, + "y": 50 + }, + "AURORA-9005": { + "h": 120, + "w": 240, + "x": 912, + "y": 40 + }, + "AURORA-9006": { + "h": 120, + "w": 240, + "x": 24, + "y": 992 + }, + "AURORA-9007": { + "h": 120, + "w": 240, + "x": 24, + "y": 302 + }, + "AURORA-9008": { + "h": 320, + "w": 280, + "x": 30, + "y": 620 + }, + "AURORA-9009": { + "h": 120, + "w": 240, + "x": 320, + "y": 302 + } + }, + "rel": { + "AURORA-9001": { + "auto": false, + "h": 120, + "other": 79, + "ry": 50, + "w": 240, + "x": 30 + }, + "AURORA-9002": { + "auto": false, + "h": 136, + "other": 79, + "ry": 50, + "w": 240, + "x": 320 + }, + "AURORA-9003": { + "auto": true, + "h": 120, + "other": 79, + "ry": 176, + "w": 240, + "x": 24 + }, + "AURORA-9004": { + "auto": false, + "h": 200, + "other": 79, + "ry": 50, + "w": 240, + "x": 30 + }, + "AURORA-9005": { + "auto": true, + "h": 120, + "other": 79, + "ry": 40, + "w": 240, + "x": 912 + }, + "AURORA-9006": { + "auto": true, + "h": 120, + "other": 79, + "ry": 40, + "w": 240, + "x": 24 + }, + "AURORA-9007": { + "auto": true, + "h": 120, + "other": 79, + "ry": 40, + "w": 240, + "x": 24 + }, + "AURORA-9008": { + "auto": false, + "h": 320, + "other": 79, + "ry": 50, + "w": 280, + "x": 30 + }, + "AURORA-9009": { + "auto": true, + "h": 120, + "other": 79, + "ry": 40, + "w": 240, + "x": 320 + } + } + }, + "visible": { + "lanes": [ + { + "keys": [ + "AURORA-9004", + "AURORA-9005" + ], + "name": "Гудвилл" + }, + { + "keys": [ + "AURORA-9001", + "AURORA-9002", + "AURORA-9003", + "AURORA-9007", + "AURORA-9009" + ], + "name": "Тестовый эпик" + }, + { + "keys": [ + "AURORA-9008" + ], + "name": "AURORA-9999" + }, + { + "keys": [ + "AURORA-9006" + ], + "name": "Без эпика" + } + ], + "links": [ + { + "crit": false, + "dst": "AURORA-9002", + "hand": true, + "src": "AURORA-9001", + "type": "блокирует начало" + }, + { + "crit": false, + "dst": "AURORA-9001", + "hand": true, + "src": "AURORA-9003", + "type": "блокирует начало" + }, + { + "crit": false, + "dst": "AURORA-9005", + "hand": false, + "src": "AURORA-9002", + "type": "блокирует завершение" + }, + { + "crit": false, + "dst": "AURORA-9002", + "hand": true, + "src": "AURORA-9004", + "type": "блокирует начало" + }, + { + "crit": false, + "dst": "AURORA-9008", + "hand": false, + "src": "AURORA-9001", + "type": "связанные" + }, + { + "crit": false, + "dst": "AURORA-9009", + "hand": true, + "src": "AURORA-9007", + "type": "блокирует начало" + } + ], + "nodes": [ + { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9001", + "kind": "разработка", + "prio": "Высокий", + "sprint": "AWPCA_Спринт 2", + "status": "В работе", + "stub": false, + "title": "Схема свода", + "who": "Иванов Иван Иванович" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-16", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9003", + "kind": "UI", + "prio": "Критичный", + "sprint": "AWPCA_Спринт 2", + "status": "Backlog", + "stub": false, + "title": "Макет экрана", + "who": "" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-25", + "epic": "Гудвилл", + "focus": false, + "key": "AURORA-9004", + "kind": "решение", + "prio": "Наивысший", + "sprint": "AWPCA_Спринт 3", + "status": "К выполнению", + "stub": false, + "title": "Решение по порогам", + "who": "Сидорова Анна" + }, + { + "crit": false, + "dim": false, + "due": "", + "epic": "Гудвилл", + "focus": false, + "key": "AURORA-9005", + "kind": "тестирование", + "prio": "Низкий", + "sprint": "AWPCA_Спринт 3", + "status": "Ожидание", + "stub": false, + "title": "Тест-кейсы", + "who": "Петров Пётр Петрович" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-10", + "epic": "Без эпика", + "focus": false, + "key": "AURORA-9006", + "kind": "Задача", + "prio": "Средний", + "sprint": "", + "status": "Готово", + "stub": false, + "title": "Задача без эпика", + "who": "Иванов Иван Иванович" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-12", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9007", + "kind": "аналитика", + "prio": "Средний", + "sprint": "AWPCA_Спринт 1", + "status": "В работе", + "stub": false, + "title": "Сосед по связям", + "who": "Kozlov-AA" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-18", + "epic": "AURORA-9999", + "focus": false, + "key": "AURORA-9008", + "kind": "актуализация", + "prio": "Самый низкий", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "Актуализация ТЗ", + "who": "Иванов Иван Иванович" + }, + { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9009", + "kind": "организационная", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "", + "stub": false, + "title": "Созвон с заказчиком", + "who": "Иванов Иван Иванович" + } + ], + "problems": { + "dates": [ + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9001", + "kind": "разработка", + "prio": "Высокий", + "sprint": "AWPCA_Спринт 2", + "status": "В работе", + "stub": false, + "title": "Схема свода", + "who": "Иванов Иван Иванович" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + "text": "сроки совпадают (20.10): запаса нет" + }, + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-25", + "epic": "Гудвилл", + "focus": false, + "key": "AURORA-9004", + "kind": "решение", + "prio": "Наивысший", + "sprint": "AWPCA_Спринт 3", + "status": "К выполнению", + "stub": false, + "title": "Решение по порогам", + "who": "Сидорова Анна" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + "text": "блокер должен закончиться 25.10, а зависимая — уже 20.10" + } + ], + "hand": [ + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9001", + "kind": "разработка", + "prio": "Высокий", + "sprint": "AWPCA_Спринт 2", + "status": "В работе", + "stub": false, + "title": "Схема свода", + "who": "Иванов Иван Иванович" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + "text": "Петров П. П. ждёт Иванов И. И." + }, + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-25", + "epic": "Гудвилл", + "focus": false, + "key": "AURORA-9004", + "kind": "решение", + "prio": "Наивысший", + "sprint": "AWPCA_Спринт 3", + "status": "К выполнению", + "stub": false, + "title": "Решение по порогам", + "who": "Сидорова Анна" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + "text": "Петров П. П. ждёт Сидорова А." + }, + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-12", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9007", + "kind": "аналитика", + "prio": "Средний", + "sprint": "AWPCA_Спринт 1", + "status": "В работе", + "stub": false, + "title": "Сосед по связям", + "who": "Kozlov-AA" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9009", + "kind": "организационная", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "", + "stub": false, + "title": "Созвон с заказчиком", + "who": "Иванов Иван Иванович" + }, + "text": "Иванов И. И. ждёт Kozlov-AA" + } + ], + "late": [ + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-16", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9003", + "kind": "UI", + "prio": "Критичный", + "sprint": "AWPCA_Спринт 2", + "status": "Backlog", + "stub": false, + "title": "Макет экрана", + "who": "" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9001", + "kind": "разработка", + "prio": "Высокий", + "sprint": "AWPCA_Спринт 2", + "status": "В работе", + "stub": false, + "title": "Схема свода", + "who": "Иванов Иван Иванович" + }, + "text": "блокер в статусе «Backlog», а до срока зависимой 6 дн." + }, + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-25", + "epic": "Гудвилл", + "focus": false, + "key": "AURORA-9004", + "kind": "решение", + "prio": "Наивысший", + "sprint": "AWPCA_Спринт 3", + "status": "К выполнению", + "stub": false, + "title": "Решение по порогам", + "who": "Сидорова Анна" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9002", + "kind": "разработка", + "prio": "Средний", + "sprint": "AWPCA_Спринт 2", + "status": "Ожидание разработки", + "stub": false, + "title": "API свода", + "who": "Петров Пётр Петрович" + }, + "text": "блокер в статусе «К выполнению», а до срока зависимой 6 дн." + } + ], + "unassigned": [ + { + "a": { + "crit": false, + "dim": false, + "due": "2026-10-16", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9003", + "kind": "UI", + "prio": "Критичный", + "sprint": "AWPCA_Спринт 2", + "status": "Backlog", + "stub": false, + "title": "Макет экрана", + "who": "" + }, + "b": { + "crit": false, + "dim": false, + "due": "2026-10-20", + "epic": "Тестовый эпик", + "focus": false, + "key": "AURORA-9001", + "kind": "разработка", + "prio": "Высокий", + "sprint": "AWPCA_Спринт 2", + "status": "В работе", + "stub": false, + "title": "Схема свода", + "who": "Иванов Иван Иванович" + }, + "text": "Иванов И. И. ждёт задачу без исполнителя" + } + ] + }, + "removed": { + "core": 9, + "ctx": 0, + "edge": 0, + "loose": 0 + } + } + }, "sprint-2": { "emptyReason": null, "layout": { diff --git a/web/src/board/__fixtures__/scenarios.json b/web/src/board/__fixtures__/scenarios.json index 1390c4f..e225c00 100644 --- a/web/src/board/__fixtures__/scenarios.json +++ b/web/src/board/__fixtures__/scenarios.json @@ -10,5 +10,6 @@ {"name": "lane-order", "filters": {}, "laneOrder": ["Гудвилл", "Без эпика", "Тестовый эпик"]}, {"name": "by-date", "filters": {"byDate": true}}, {"name": "saved-folded", "filters": {}, "saved": {"AURORA-9002": {"x": 600, "ry": 80, "w": 280, "h": 160}, "AURORA-9003": {"x": 30, "ry": 50}}, "folded": ["Гудвилл"]}, - {"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}} + {"name": "by-date-colw", "filters": {"byDate": true}, "colWidths": {"2026-10-20": 400}, "savedDate": {"AURORA-9001": {"x": 10, "ry": 60, "w": 250}}}, + {"name": "saved-short", "filters": {}, "saved": {"AURORA-9001": {"x": 30, "ry": 50, "w": 240, "h": 120}, "AURORA-9002": {"x": 320, "ry": 50, "w": 240, "h": 136}, "AURORA-9004": {"x": 30, "ry": 50, "w": 240, "h": 200}, "AURORA-9008": {"x": 30, "ry": 50, "w": 280, "h": 320}}} ] diff --git a/web/src/board/analysis/facets.ts b/web/src/board/analysis/facets.ts index 0840e52..c432857 100644 --- a/web/src/board/analysis/facets.ts +++ b/web/src/board/analysis/facets.ts @@ -56,7 +56,9 @@ export function initialSprints(model: BoardModel, f: Filters, remembered: unknow } // список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям» export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set { - if (!model.nodes.length || whoBox(model, f).kind !== "multi") return new Set(); + if (!model.nodes.length) return new Set(); const { total } = whoItems(model, f); + const names = Object.keys(total); + if (!names.length || (names.length === 1 && names[0] === "")) return new Set(); return new Set(strList(remembered).filter((w) => total[w] !== undefined)); } diff --git a/web/src/board/model/fromJson.ts b/web/src/board/model/fromJson.ts index 587565a..41fe289 100644 --- a/web/src/board/model/fromJson.ts +++ b/web/src/board/model/fromJson.ts @@ -1,7 +1,7 @@ import { CW, KEY_RE, NO_EPIC } from "../constants.ts"; +import { strList } from "../storage.ts"; import type { BoardNode, Dicts, JsonImport, Link, SavedBox } from "../types.ts"; import { addLink, addStubs } from "./links.ts"; -import { strList } from "../storage.ts"; import { sortableDate } from "./util.ts"; type Raw = Record; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы diff --git a/web/src/board/model/util.test.ts b/web/src/board/model/util.test.ts index fb88ec6..cc0f629 100644 --- a/web/src/board/model/util.test.ts +++ b/web/src/board/model/util.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { dayDiff, fmtDate, plural, shortName, sortableDate } from "./util.ts"; +import { dayDiff, emptyModel, fmtDate, fmtStamp, notStarted, plural, shortName, sortableDate, unassigned, whoKey } from "./util.ts"; describe("util", () => { it("sortableDate приводит даты Jira к ГГГГ-ММ-ДД", () => { @@ -19,4 +19,17 @@ describe("util", () => { expect(dayDiff("2026-10-14", "2026-10-20")).toBe(6); expect(dayDiff("", "2026-10-20")).toBeNull(); }); + it("notStarted, unassigned, whoKey, fmtStamp, emptyModel", () => { + expect(notStarted("Backlog")).toBe(true); + expect(notStarted("К выполнению")).toBe(true); + expect(notStarted("В работе")).toBe(false); + expect(unassigned("")).toBe(true); + expect(unassigned("Не назначен")).toBe(true); + expect(unassigned("Иванов")).toBe(false); + expect(whoKey(" Иванов ")).toBe("Иванов"); + expect(whoKey("unassigned")).toBe(""); + expect(fmtStamp("не дата")).toBe("не дата"); + expect(fmtStamp("2026-10-14T09:05:00")).toMatch(/^14\.10 09:05$/); + expect(emptyModel("пусто")).toMatchObject({ nodes: [], links: [], emptyText: "пусто", missing: 0 }); + }); }); diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts index fdd6184..0c926be 100644 --- a/web/src/board/storage.test.ts +++ b/web/src/board/storage.test.ts @@ -5,13 +5,14 @@ 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 { exportJson } from "./exportJson.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, migrateMode, migrateStore, readPrefs, saveLayout, savedLayout, setColWidth, writeFlag } from "./storage.ts"; +import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, 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"); @@ -80,3 +81,48 @@ describe("хранилище = links.html@3931a88", () => { 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(); + }); +}); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts index 8b5558d..fbd132d 100644 --- a/web/src/board/storage.ts +++ b/web/src/board/storage.ts @@ -48,7 +48,8 @@ export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), [] export const foldedSet = (): Set => new Set(strList(store.json(fileKey("fold"), []))); export function colWidths(): Record { const v = store.json(fileKey("colw"), {}); - return isObj(v) ? v as Record : {}; + if (!isObj(v)) return {}; + return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record; // T9.1 } const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища diff --git a/web/src/test/canon.test.ts b/web/src/test/canon.test.ts index 9558883..a6fd782 100644 --- a/web/src/test/canon.test.ts +++ b/web/src/test/canon.test.ts @@ -11,4 +11,7 @@ describe("canon", () => { expect(canon(foreign)).toEqual(["a", "b"]); expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]); }); + it("порядок Set не зависит от локали: сравнение по кодам символов", () => { + expect(canon(new Set(["б", "Б", "b", "B"]))).toEqual(["B", "b", "Б", "б"]); + }); }); diff --git a/web/src/test/canon.ts b/web/src/test/canon.ts index f251c09..820c3c2 100644 --- a/web/src/test/canon.ts +++ b/web/src/test/canon.ts @@ -3,7 +3,7 @@ const isSet = (v: unknown): v is Set => Object.prototype.toString.call(v) === "[object Set]"; export function canon(v: unknown): unknown { - if (isSet(v)) return [...v].map(canon).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (isSet(v)) return [...v].map(canon).sort((a, b) => { const x = JSON.stringify(a), y = JSON.stringify(b); return x < y ? -1 : x > y ? 1 : 0; }); if (Array.isArray(v)) return v.map(canon); if (v && typeof v === "object") { const o = v as Record;