web-app-followups: 46 отложенных пунктов веб-приложения (#29) #33

Merged
aep merged 19 commits from feat/29-web-app-followups into main 2026-10-15 09:22:27 +03:00
9 changed files with 856 additions and 8 deletions
Showing only changes of commit dd95e85a25 - Show all commits

View File

@ -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": "без срока<span class=\"n\">1</span>",
"left": "20px",
"title": "плановая дата не задана"
},
{
"className": "dlabel past",
"innerHTML": "10.10<span class=\"n\">1</span>",
"left": "316px",
"title": "срок прошёл"
},
{
"className": "dlabel past",
"innerHTML": "12.10<span class=\"n\">1</span>",
"left": "612px",
"title": "срок прошёл"
},
{
"className": "dlabel",
"innerHTML": "16.10<span class=\"n\">1</span>",
"left": "908px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "18.10<span class=\"n\">1</span>",
"left": "1204px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "20.10<span class=\"n\">3</span>",
"left": "1500px",
"title": "плановая дата окончания"
},
{
"className": "dlabel",
"innerHTML": "25.10<span class=\"n\">1</span>",
"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": {

View File

@ -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}}}
]

View File

@ -56,7 +56,9 @@ export function initialSprints(model: BoardModel, f: Filters, remembered: unknow
}
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
export function initialWho(model: BoardModel, f: Filters, remembered: unknown): Set<string> {
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));
}

View File

@ -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<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- внешний JSON произвольной формы

View File

@ -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 });
});
});

View File

@ -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();
});
});

View File

@ -48,7 +48,8 @@ export const laneOrder = (): string[] => strList(store.json(fileKey("lanes"), []
export const foldedSet = (): Set<string> => new Set(strList(store.json(fileKey("fold"), [])));
export function colWidths(): Record<string, number> {
const v = store.json<unknown>(fileKey("colw"), {});
return isObj(v) ? v as Record<string, number> : {};
if (!isObj(v)) return {};
return Object.fromEntries(Object.entries(v).filter(([, w]) => num(w))) as Record<string, number>; // T9.1
}
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища

View File

@ -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", "Б", "б"]);
});
});

View File

@ -3,7 +3,7 @@
const isSet = (v: unknown): v is Set<unknown> => 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<string, unknown>;