fix(site-finder): remount analysis-дерева при смене cad (#2445-B4) (#2454)
This commit is contained in:
parent
de5da6ec35
commit
c0c666cccc
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>
|
</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>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue