fix(site-finder): remount analysis-дерева при смене cad (#2445-B4) #2454
2 changed files with 185 additions and 1 deletions
|
|
@ -0,0 +1,172 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisPageContent } from "../AnalysisPageContent";
|
||||
|
||||
// ── Regression test: epic #2445 item B4 ──────────────────────────────────────
|
||||
//
|
||||
// Bug: AnalysisPageContent (and its §7 Section7Concept child) was rendered
|
||||
// without a React `key` tied to `cad`. Client-side nav between two different
|
||||
// parcels under the SAME /site-finder/analysis/[cad] route template (e.g.
|
||||
// RecentParcels / CompareTable → plain next/link to a different cad) reused
|
||||
// the same component instance — instance-local state (§7's useCreateConcept
|
||||
// mutation result, which has no cache key: it lives only in
|
||||
// concept.data/isSuccess/isError) kept showing the OLD parcel's computed
|
||||
// concept until the user manually re-ran it.
|
||||
//
|
||||
// Fix: page.tsx now renders <AnalysisPageContent key={cad} cad={cad} /> so a
|
||||
// parcel change forces a clean remount. This test exercises that exact
|
||||
// pattern (rendering AnalysisPageContent keyed by cad, same as page.tsx) and
|
||||
// asserts:
|
||||
// 1. Same cad, re-render → local state in a §7-like child is PRESERVED
|
||||
// (normal interaction, e.g. horizon change, must not lose progress).
|
||||
// 2. Different cad → local state RESETS (remount), instead of leaking the
|
||||
// previous parcel's concept into the new parcel's view.
|
||||
//
|
||||
// The real §7Concept/useCreateConcept + all sibling sections pull in Leaflet/
|
||||
// ECharts/TanStack Query fetches — irrelevant to the remount contract under
|
||||
// test — so every section is mocked with a light stub. The Section7Concept
|
||||
// stub mirrors the real bug shape: a plain useState "concept result" with NO
|
||||
// cad-keyed cache, set via a button click (stand-in for the mutation firing).
|
||||
|
||||
vi.mock("@/lib/site-finder-api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/site-finder-api")>(
|
||||
"@/lib/site-finder-api",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useParcelAnalyzeQuery: (cad: string) => ({
|
||||
data: { district: null, egrn: null, gate_verdict: null, cad },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/site-finder/ChatDock", () => ({
|
||||
ChatDock: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
|
||||
GateVerdictBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/HorizonSelector", () => ({
|
||||
HorizonSelector: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
|
||||
Section1ParcelInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
|
||||
Section2NetworksUtilities: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/components/site-finder/analysis/Section3SettingsAndCompetitors",
|
||||
() => ({ Section3SettingsAndCompetitors: () => null }),
|
||||
);
|
||||
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
|
||||
Section4Estimate: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
|
||||
Section5Atmosphere: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
|
||||
Section6Forecast: () => null,
|
||||
}));
|
||||
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
|
||||
SectionAlternatives: () => null,
|
||||
}));
|
||||
|
||||
// Stub §7: a plain useState "concept" with no cad-keyed cache — the exact
|
||||
// shape of the real useCreateConcept()-backed bug. A button click simulates
|
||||
// the mutation firing and settling.
|
||||
vi.mock("@/components/site-finder/analysis/Section7Concept", () => {
|
||||
const React = require("react") as typeof import("react");
|
||||
return {
|
||||
Section7Concept: () => {
|
||||
const [concept, setConcept] = React.useState<string | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setConcept("computed-concept")}>
|
||||
Сгенерировать концепцию
|
||||
</button>
|
||||
<div data-testid="concept-state">{concept ?? "empty"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// AnalysisPageContent calls useQueryClient() directly (horizon-change
|
||||
// invalidation) even though useParcelAnalyzeQuery is mocked above, so every
|
||||
// render needs a real QueryClientProvider ancestor.
|
||||
function renderKeyed(cad: string) {
|
||||
const client = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent key={cad} cad={cad} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AnalysisPageContent — key={cad} remount contract (#2445 B4)", () => {
|
||||
it("preserves §7-like local state across a re-render with the SAME cad", () => {
|
||||
const client = new QueryClient();
|
||||
const cad = "66:41:0204016:10";
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent key={cad} cad={cad} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Сгенерировать концепцию" }),
|
||||
);
|
||||
expect(screen.getByTestId("concept-state").textContent).toBe(
|
||||
"computed-concept",
|
||||
);
|
||||
|
||||
// Same cad → same key → React must NOT remount; in-progress state stays
|
||||
// (e.g. a horizon change re-renders the same tree — must not lose it).
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent key={cad} cad={cad} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("concept-state").textContent).toBe(
|
||||
"computed-concept",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets §7-like local state when navigating to a DIFFERENT cad", () => {
|
||||
// Single render tree that we rerender() with a CHANGED key — this pins the
|
||||
// `key={cad}` mechanism itself. A fresh unmount()+render() would reset state
|
||||
// unconditionally (even with no key), so it would pass on a reverted fix and
|
||||
// wouldn't guard the regression. With rerender() + changed key, React keeps
|
||||
// the tree mounted and only remounts because the key differs — exactly what
|
||||
// page.tsx's `key={cad}` does on client-side nav. Drop the key in page.tsx
|
||||
// and the OLD instance is reused, leaking "computed-concept" → this fails.
|
||||
const client = new QueryClient();
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent key="66:41:0204016:10" cad="66:41:0204016:10" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Сгенерировать концепцию" }),
|
||||
);
|
||||
expect(screen.getByTestId("concept-state").textContent).toBe(
|
||||
"computed-concept",
|
||||
);
|
||||
|
||||
// Client-side nav to a different parcel: same tree position, different key.
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<AnalysisPageContent key="66:41:0702033:20" cad="66:41:0702033:20" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("concept-state").textContent).toBe("empty");
|
||||
});
|
||||
});
|
||||
|
|
@ -47,7 +47,19 @@ export default async function AnalysisPage({ params }: PageProps) {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent cad={cad} />
|
||||
{/* key={cad}: client-side nav between two parcels under this same route
|
||||
template (RecentParcels / CompareTable link straight to /analysis/
|
||||
CAD_B) does NOT remount by default — App Router reuses the instance,
|
||||
so instance-local state (§7's useCreateConcept mutation result +
|
||||
autoRun refs, §3's selected competitor/filters, the chat thread in
|
||||
ChatDock/ChatPanel) would keep showing the OLD parcel's values until
|
||||
manually re-run/reselected. Forcing a remount on cad change resets
|
||||
all of it cleanly. Queries under this tree (parcel-analyze,
|
||||
parcel-forecast, …) are TanStack Query and keyed by cad, so a
|
||||
remount just re-subscribes to the (already cached) query result —
|
||||
no extra fetch waterfall. Same cad → same key → React does NOT
|
||||
remount (a normal re-render, e.g. horizon change, keeps state). */}
|
||||
<AnalysisPageContent key={cad} cad={cad} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue