182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
"""Helpers for asserting frontend wiring from the backend test suite.
|
|
|
|
Most of this suite checks frontend behaviour by reading TSX files and
|
|
asserting that literal substrings occur in them. That reds the suite on every
|
|
rename and every copy change while proving nothing about behaviour: renaming a
|
|
button label is not a regression, and ``source.count("useEffect(") == 1`` is a
|
|
formatting rule, not a contract.
|
|
|
|
These helpers keep the useful half of that idea — that a documented product
|
|
contract must remain wired somewhere in the frontend — and drop the brittle
|
|
half. Assert on identifiers, API paths and prop names, which only change when
|
|
the wiring genuinely changes. Do not assert on user-visible copy; put that in
|
|
a frontend component test where the rendered output can be checked properly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
FRONTEND_SRC = ROOT / "frontend" / "src"
|
|
|
|
|
|
# A feature is one behaviour spread over several modules: a container, its
|
|
# hooks, its domain layer, its pure helpers. A contract belongs to the feature,
|
|
# not to whichever file currently holds it, so moving code between siblings
|
|
# must not red the suite. Missing entries are skipped, so a group survives a
|
|
# module being split further, renamed or merged back.
|
|
#
|
|
# Use these for *positive* contracts ("this is wired"). A negative contract
|
|
# ("this component performs no transport") is a statement about one file and
|
|
# must keep reading that file, or widening it would quietly weaken the check.
|
|
FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
|
"map_workspace": (
|
|
"components/map/MapWorkspace.tsx",
|
|
"components/map/mapWorkspaceProps.ts",
|
|
"components/map/mapWorkspaceThemes.ts",
|
|
"components/map/mapWorkspaceUtils.ts",
|
|
"components/map/useMapWorkspaceViewModel.ts",
|
|
"components/map/MapExplorerView.tsx",
|
|
"components/map/MapAdvancedWorkbench.tsx",
|
|
"components/map/MunicipalitySearch.tsx",
|
|
"hooks/useMapImageOverlays.ts",
|
|
"hooks/useMapRectangleSelection.ts",
|
|
"hooks/useFullGisWorkflow.ts",
|
|
"hooks/useMapThemeSelectionInsights.ts",
|
|
"hooks/useMapSelectionExtract.ts",
|
|
"hooks/useMapWorkspaceState.ts",
|
|
"hooks/useMapSelectionDataset.ts",
|
|
"hooks/useMapSelectionQa.ts",
|
|
"hooks/useMapThemeSelectionInsights.ts",
|
|
"hooks/useTemporalComparison.ts",
|
|
"hooks/useCoverageResolver.ts",
|
|
"hooks/useOfficialMapProducts.ts",
|
|
),
|
|
# The presentational half of the map workspace. Transport belongs to the
|
|
# hooks, so "this performs no transport" is a contract about these modules
|
|
# and would fail — correctly — against the whole feature.
|
|
"map_workspace_presentation": (
|
|
"components/map/MapWorkspace.tsx",
|
|
"components/map/MapExplorerView.tsx",
|
|
"components/map/MapAdvancedWorkbench.tsx",
|
|
),
|
|
"detection": (
|
|
"components/detection/DetectionLab.tsx",
|
|
"components/detection/DetectionModelManagement.tsx",
|
|
"components/detection/detectionProfiles.ts",
|
|
"components/models/ModelSelector.tsx",
|
|
"components/models/modelOptions.ts",
|
|
"hooks/useDetectionWorkflow.ts",
|
|
),
|
|
"segmentation": (
|
|
"components/segmentation/SegmentationLab.tsx",
|
|
"hooks/useSegmentationWorkflow.ts",
|
|
),
|
|
"quality": (
|
|
"components/quality/QualityResultsPanel.tsx",
|
|
"components/quality/DetectionReviewPanel.tsx",
|
|
"hooks/useQualityWorkflow.ts",
|
|
),
|
|
"datasets": (
|
|
"components/datasets/DatasetPanel.tsx",
|
|
"components/datasets/DatasetDetailPanel.tsx",
|
|
"components/datasets/RasterControls.tsx",
|
|
"components/datasets/VectorControls.tsx",
|
|
"components/datasets/SourceCatalogPanel.tsx",
|
|
"hooks/useDatasetWorkflow.ts",
|
|
"services/api/datasets.ts",
|
|
),
|
|
"exports": (
|
|
"components/exports/ExportCenter.tsx",
|
|
"hooks/useExportWorkflow.ts",
|
|
),
|
|
"shell": (
|
|
"App.tsx",
|
|
"WorkbenchApp.tsx",
|
|
"components/shell/WorkbenchNavigation.tsx",
|
|
"components/shell/SecondaryDisplay.tsx",
|
|
"components/inspector/WorkbenchInspector.tsx",
|
|
"components/overview/OverviewWorkspace.tsx",
|
|
"hooks/useProjectWorkspace.ts",
|
|
"hooks/useWorkbenchBootstrap.ts",
|
|
),
|
|
}
|
|
|
|
MAP_WORKSPACE_SOURCES = FEATURE_SOURCES["map_workspace"]
|
|
|
|
|
|
def read_frontend(relative_path: str) -> str:
|
|
"""Read one frontend source file relative to ``frontend/src``."""
|
|
|
|
return (FRONTEND_SRC / relative_path).read_text(encoding="utf-8")
|
|
|
|
|
|
def read_frontend_area(*relative_paths: str) -> str:
|
|
"""Read several related sources as one body of code.
|
|
|
|
Files that do not exist are skipped, so this survives a module being split
|
|
further or merged back.
|
|
"""
|
|
|
|
parts: list[str] = []
|
|
for relative_path in relative_paths:
|
|
path = FRONTEND_SRC / relative_path
|
|
if path.is_file():
|
|
parts.append(path.read_text(encoding="utf-8"))
|
|
return chr(10).join(parts)
|
|
|
|
|
|
def read_feature(name: str) -> str:
|
|
"""Every module of one feature, whichever files it is currently split into."""
|
|
|
|
try:
|
|
sources = FEATURE_SOURCES[name]
|
|
except KeyError: # pragma: no cover - a typo should fail loudly
|
|
raise AssertionError(
|
|
f"Unknown frontend feature {name!r}; known: {sorted(FEATURE_SOURCES)}"
|
|
) from None
|
|
return read_frontend_area(*sources)
|
|
|
|
|
|
def read_map_workspace() -> str:
|
|
"""The whole map workspace feature, whichever modules it is split into."""
|
|
|
|
return read_feature("map_workspace")
|
|
|
|
|
|
def assert_wired(source: str, *identifiers: str, context: str = "frontend source") -> None:
|
|
"""Every identifier must appear in ``source``.
|
|
|
|
Use for symbols, hook names, prop names and API paths — things a refactor
|
|
renames deliberately — never for sentences shown to a user.
|
|
"""
|
|
|
|
missing = [identifier for identifier in identifiers if identifier not in source]
|
|
assert not missing, f"{context} no longer wires: {missing}"
|
|
|
|
|
|
def assert_calls(source: str, function_name: str, *, first_argument: str) -> None:
|
|
"""Assert ``function_name`` is called with ``first_argument`` as argument 1.
|
|
|
|
Tolerates whatever the remaining arguments have been refactored into, which
|
|
is the part that keeps changing while the wiring stays the same.
|
|
"""
|
|
|
|
pattern = rf"{re.escape(function_name)}\(\s*{re.escape(first_argument)}\s*[,)]"
|
|
assert re.search(pattern, source), f"{function_name}({first_argument}, …) is no longer called"
|
|
|
|
|
|
def assert_mentions(source: str, *phrases: str, context: str = "frontend source") -> None:
|
|
"""Case-insensitive check that a concept is still surfaced to the operator.
|
|
|
|
A deliberately weak assertion: it survives rewording but still fails if a
|
|
whole explanation is deleted. Prefer ``assert_wired`` where an identifier
|
|
exists to check instead.
|
|
"""
|
|
|
|
folded = source.casefold()
|
|
missing = [phrase for phrase in phrases if phrase.casefold() not in folded]
|
|
assert not missing, f"{context} no longer mentions: {missing}"
|