Веб: вики — дерево не раскрывает предков при новом nodes (#91)

Эффект раскрытия предков зависит только от selected; тест с новым массивом nodes;
заглушки URL.createObjectURL в тесте загрузки через spyOn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 03:23:12 +04:00
parent 5a142f772c
commit a37ce0a59c
3 changed files with 26 additions and 6 deletions

View File

@ -45,4 +45,18 @@ describe("Tree", () => {
await userEvent.keyboard("{Enter}");
expect(onOpen).toHaveBeenLastCalledWith("A");
});
it("новый массив nodes не раскрывает свёрнутого родителя; смена selected раскрывает предков нового", async () => {
const { rerender } = render(<Tree nodes={NODES} selected="C" onOpen={() => {}} />);
item("B").focus();
await userEvent.keyboard("{ArrowLeft}");
expect(item("B")).toHaveAttribute("aria-expanded", "false");
rerender(<Tree nodes={[...NODES]} selected="C" onOpen={() => {}} />); // та же выборка, новая ссылка
expect(item("B")).toHaveAttribute("aria-expanded", "false");
rerender(<Tree nodes={[...NODES, N("E", 2, "B")]} selected="C" onOpen={() => {}} />); // с добавленным узлом
expect(item("B")).toHaveAttribute("aria-expanded", "false");
rerender(<Tree nodes={[...NODES, N("E", 2, "B")]} selected="E" onOpen={() => {}} />); // другая страница
expect(item("B")).toHaveAttribute("aria-expanded", "true");
expect(item("E")).toBeInTheDocument();
});
});

View File

@ -12,13 +12,17 @@ export function Tree({ nodes, selected, onOpen }: { nodes: WikiNode[]; selected:
const [focus, setFocus] = useState<string | null>(null);
const refs = useRef(new Map<string, HTMLDivElement>());
// предки выбранной страницы раскрываются при её смене; ручное сворачивание потом не откатывается (зависимости без 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++) {

View File

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