diff --git a/web/src/modules/wiki/Tree.test.tsx b/web/src/modules/wiki/Tree.test.tsx index de4ecc7..c0b2d06 100644 --- a/web/src/modules/wiki/Tree.test.tsx +++ b/web/src/modules/wiki/Tree.test.tsx @@ -45,4 +45,18 @@ describe("Tree", () => { await userEvent.keyboard("{Enter}"); expect(onOpen).toHaveBeenLastCalledWith("A"); }); + + it("новый массив nodes не раскрывает свёрнутого родителя; смена selected раскрывает предков нового", async () => { + const { rerender } = render( {}} />); + item("B").focus(); + await userEvent.keyboard("{ArrowLeft}"); + expect(item("B")).toHaveAttribute("aria-expanded", "false"); + rerender( {}} />); // та же выборка, новая ссылка + expect(item("B")).toHaveAttribute("aria-expanded", "false"); + rerender( {}} />); // с добавленным узлом + expect(item("B")).toHaveAttribute("aria-expanded", "false"); + rerender( {}} />); // другая страница + expect(item("B")).toHaveAttribute("aria-expanded", "true"); + expect(item("E")).toBeInTheDocument(); + }); }); diff --git a/web/src/modules/wiki/Tree.tsx b/web/src/modules/wiki/Tree.tsx index 4429652..a381e56 100644 --- a/web/src/modules/wiki/Tree.tsx +++ b/web/src/modules/wiki/Tree.tsx @@ -12,13 +12,17 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected: const [focus, setFocus] = useState(null); const refs = useRef(new Map()); - // предки выбранной страницы раскрываются при её смене; ручное сворачивание потом не откатывается (зависимости без open) + // предки выбранной страницы раскрываются только при смене selected: новый массив nodes (перечитанное дерево) и ручное + // сворачивание родителя этого не повторяют; актуальные узлы читаются через ref и в зависимости не входят + const byCodeRef = useRef(byCode); + useEffect(() => { byCodeRef.current = byCode; }); useEffect(() => { if (!selected) return; + const map = byCodeRef.current; const up: string[] = []; - for (let n = byCode.get(selected)?.parent_code; n && !up.includes(n); n = byCode.get(n)?.parent_code ?? null) up.push(n); + for (let n = map.get(selected)?.parent_code; n && !up.includes(n); n = map.get(n)?.parent_code ?? null) up.push(n); if (up.length) setOpen((o) => (up.every((c) => o.has(c)) ? o : new Set([...o, ...up]))); - }, [selected, byCode]); + }, [selected]); const visible = useMemo(() => nodes.filter((n) => { for (let p = n.parent_code, guard = 0; p && guard < nodes.length; p = byCode.get(p)?.parent_code ?? null, guard++) { diff --git a/web/src/modules/wiki/download.test.tsx b/web/src/modules/wiki/download.test.tsx index 37949f7..b07fe22 100644 --- a/web/src/modules/wiki/download.test.tsx +++ b/web/src/modules/wiki/download.test.tsx @@ -14,11 +14,13 @@ describe("useWikiDownload", () => { setToken("tok"); vi.useFakeTimers(); clicked = []; - URL.createObjectURL = vi.fn(() => "blob:fake"); - URL.revokeObjectURL = vi.fn(); + // в jsdom этих методов нет: создаём заглушки и снимаем в afterEach (vi.restoreAllMocks) + Object.assign(URL, { createObjectURL: () => "", revokeObjectURL: () => {} }); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push(this.download); }); }); - afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(URL, "createObjectURL"); Reflect.deleteProperty(URL, "revokeObjectURL"); }); it("fetching → опрос раз в 2 с → ready → файл скачан один раз под своим именем", async () => { let gets = 0;