diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index b00b539..beeab1f 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -298,3 +298,4 @@ в `App.test.tsx` ожидание «/app ведёт на доску» — через `waitFor`.** Обоснование: без `/projects` оболочка не показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты. +35. 2026-10-19 — **в тесте `storage.test.ts` выражение `{…} as JsonImport` заменено на `as unknown as JsonImport`.** Обоснование: `tsc --noEmit` (шаг `make check`) отвергает прямое приведение частичного объекта к `JsonImport` (нет `model`, `dateColumns`, `collapsedLanes`); в тесте важны только `layout`, `layoutByDate`, `laneOrder`, `filters`, и `applyJsonSettings` читает только их. Цена ошибки: тест передаёт неполный импорт; если `applyJsonSettings` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа. diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index f735dca..afa9e82 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -26,7 +26,12 @@ function hub(over: Record Response> = {}) { return json(404, { error: "not_found" }); }); } -const page = () => renderWithProviders(, { route: "/board", path: "/board" }); +const page = (project = "AWPCA") => renderWithProviders(, { route: "/board", path: "/board" }); +// канбан-проект: спринтов нет ни у одной своей задачи (соседи — из другого проекта — могут иметь) +const withSprints = (keep: (key: string) => boolean) => ({ ...SNAP, issues: SNAP.issues.map((i: { key: string; sprint: string | null }) => ({ ...i, sprint: keep(i.key) ? i.sprint : null })) }); +const KANBAN = withSprints(() => false); +const NEIGHBOR_ONLY = withSprints((k) => k === "AURORA-9007"); +const NO_SPRINT_TEXT = "колонки «Спринт» нет — показаны все задачи"; describe("страница доски", () => { beforeEach(() => setToken("tok")); @@ -166,4 +171,39 @@ describe("страница доски", () => { expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument(); expect(screen.queryByRole("listbox")).toBeNull(); }); + + it("T9: старая раскладка переносится только в проект AWPCA", async () => { + hub(); + localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}'); + const r = page("STOCKS_DRRK"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull(); + expect(localStorage.getItem("graph-pos:board")).toBeNull(); + r.unmount(); + page("AWPCA"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}'); + }); + + it("T9: канбан-проект — без спринтов в снимке: надпись вместо фильтра спринтов", async () => { + hub({ "GET /board": () => json(200, KANBAN) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + }); + + it("T9: канбан-проект, спринт только у соседа — надпись, карточка соседа на доске", async () => { + hub({ "GET /board": () => json(200, NEIGHBOR_ONLY) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + expect(screen.getAllByText("AURORA-9007").length).toBeGreaterThan(0); + }); + + it("T9: проект AWPCA с фикстурой hub-basic — выбор спринта есть", async () => { + hub(); + page("AWPCA"); + expect(await screen.findByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); + expect(screen.queryByText(NO_SPRINT_TEXT)).toBeNull(); + }); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index cda7acc..8ad3c78 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -21,7 +21,7 @@ export function BoardPage({ project }: { project: string }) { const closedStatuses = useClosedStatuses(); const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); const src = useBoardSource(d && !d.missing.length ? d.dicts : null, project); - const prefs = useBoardPrefs(src.model); + const prefs = useBoardPrefs(src.model, project); const today = todayIso(); const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today), [src.model, prefs.filters, prefs.store.laneOrder, today]); diff --git a/web/src/board/model/fromHub.ts b/web/src/board/model/fromHub.ts index 9c1b442..6adee88 100644 --- a/web/src/board/model/fromHub.ts +++ b/web/src/board/model/fromHub.ts @@ -26,5 +26,7 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { }); const missing = addStubs(nodes, links, NOT_IN_HUB); const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; - return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB }; + // фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым» + const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint); + return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB }; } diff --git a/web/src/board/model/model.test.ts b/web/src/board/model/model.test.ts index 0842028..a6c1588 100644 --- a/web/src/board/model/model.test.ts +++ b/web/src/board/model/model.test.ts @@ -21,7 +21,16 @@ describe("модель доски = links.html@3931a88", () => { }); it("fromHub — пустое зеркало без полного прогона", () => { const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot; - expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model); + // эталон написан, когда fromHub всегда давал hasSprint: true; без своих спринтов фильтр спринтов не показывается + expect(canon(fromHub(snap, dicts))).toEqual({ ...golden("hub-empty.expected.json").model, hasSprint: false }); + }); + it("T9: fromHub — спринта у своих задач нет (есть только у соседа) → hasSprint false", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + const none = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: null })) }; + expect(fromHub(none, dicts).hasSprint).toBe(false); + const neighborOnly = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.neighbor ? i.sprint : null })) }; + expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); + expect(fromHub(snap, dicts).hasSprint).toBe(true); }); it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { const text = fx("jira-export.csv"); diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts index 0c926be..a8bbc98 100644 --- a/web/src/board/storage.test.ts +++ b/web/src/board/storage.test.ts @@ -11,7 +11,7 @@ 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, toggleLane, writeFlag } from "./storage.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"; @@ -126,3 +126,31 @@ describe("операции хранилища", () => { 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([]); + }); +}); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts index fbd132d..f0d5518 100644 --- a/web/src/board/storage.ts +++ b/web/src/board/storage.ts @@ -1,6 +1,8 @@ // хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать) // localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени // файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища +// ключи по проектам (решения 10 и 20): раскладка и флаги доски — отдельно для каждого проекта; у AWPCA ключи прежние, у остальных — суффикс «@<метка>» +// (graph-pos:board@STOCKS_DRRK). Все функции модуля берут ключи через pref()/fileKey() и текущий набор (setStoreProject) import { COLW, GRID } from "./constants.ts"; import type { RelBox, Layout } from "./layout/layout.ts"; import type { Filters, JsonImport } from "./types.ts"; @@ -12,13 +14,19 @@ export const store = { del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } }, json(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } }, }; -export const pref = (name: string): string => "graph-" + name; +export const LEGACY_PROJECT = "AWPCA"; // у него ключи без суффикса — раскладка, сделанная до проектов, сохраняется +export function storeSuffix(label: string | null): string { return label === null || label === LEGACY_PROJECT ? "" : "@" + label; } +let suffix = ""; // набор ключей текущего проекта +export function setStoreProject(label: string | null): void { suffix = storeSuffix(label); } +export const isLegacyStore = (): boolean => suffix === ""; +export const pref = (name: string): string => "graph-" + name + suffix; const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"]; -export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD; +export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD + suffix; export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v); +// миграции — только для набора AWPCA (isLegacyStore): у остальных проектов старых ключей не было // до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии // переносим на общий ключ набор того файла, где расставлено больше всего карточек export function migrateStore(): void { diff --git a/web/src/board/useBoardPrefs.ts b/web/src/board/useBoardPrefs.ts index a97628f..13ccc73 100644 --- a/web/src/board/useBoardPrefs.ts +++ b/web/src/board/useBoardPrefs.ts @@ -23,8 +23,12 @@ const pick = (model: BoardModel, f: Filters) => ({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) }); // настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка -export function useBoardPrefs(model: BoardModel): BoardPrefsApi { - const [prefs, setPrefs] = useState(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); }); +export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi { + const [prefs, setPrefs] = useState(() => { + st.setStoreProject(project); // ключи проекта — до любого чтения; страница пересоздаётся при смене проекта (key={project}) + if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); } + return st.readPrefs(); + }); const [store, setStore] = useState(() => readStore(0)); const [sel, setSel] = useState(() => pick(model, prefs.filters)); if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho