2026-05-26 10:39:17 +08:00
import fs from "node:fs" ;
import path from "node:path" ;
function assert ( condition : unknown , message : string ) {
if ( ! condition ) throw new Error ( message );
}
function readRepoFile (... parts : string []) {
return fs . readFileSync ( path . join ( process . cwd (), ".." , ... parts ), "utf8" );
}
function readFrontendFile (... parts : string []) {
return fs . readFileSync ( path . join ( process . cwd (), ... parts ), "utf8" );
}
export function runTests() {
const repoRoot = path . join ( process . cwd (), ".." );
const sseManagerPath = path . join ( repoRoot , "web" , "sse_manager.py" );
assert ( fs . existsSync ( sseManagerPath ), "FastAPI backend must define web/sse_manager.py" );
const sseManager = fs . readFileSync ( sseManagerPath , "utf8" );
assert ( sseManager . includes ( "asyncio.Queue" ), "SSE manager must keep asyncio.Queue connections" );
assert ( sseManager . includes ( "broadcast(" ), "SSE manager must expose broadcast(city, changes)" );
2026-05-26 22:27:55 +08:00
assert ( sseManager . includes ( "broadcast_event" ), "SSE manager must broadcast stored replayable events" );
2026-05-26 10:39:17 +08:00
assert ( sseManager . includes ( "event_stream(" ), "SSE manager must expose an async event_stream(user_id)" );
2026-05-26 22:27:55 +08:00
assert ( sseManager . includes ( "_queue_cities" ), "SSE manager must track per-connection city subscriptions" );
2026-05-26 10:39:17 +08:00
assert ( sseManager . includes ( "revision" ), "SSE patches must carry monotonic revision numbers" );
assert ( sseManager . includes ( "30" ), "SSE stream must include a 30-second heartbeat" );
assert ( sseManager . includes ( "data: " ), "SSE stream must emit data: JSON frames" );
2026-05-26 22:27:55 +08:00
const schemaPath = path . join ( repoRoot , "web" , "realtime_patch_schema.py" );
assert ( fs . existsSync ( schemaPath ), "backend must define a versioned realtime patch schema module" );
const schema = fs . readFileSync ( schemaPath , "utf8" );
assert ( schema . includes ( "city_observation_patch.v1" ), "patch schema must expose city_observation_patch.v1" );
assert ( schema . includes ( "normalize_observation_patch" ), "patch schema must normalize collector payloads" );
assert ( schema . includes ( "runway_points" ), "patch schema must preserve runway point observations" );
const storePath = path . join ( repoRoot , "web" , "realtime_event_store.py" );
assert ( fs . existsSync ( storePath ), "backend must define a realtime event replay store" );
const store = fs . readFileSync ( storePath , "utf8" );
assert ( store . includes ( "observation_patch_events" ), "event store must use the SQLite observation_patch_events table" );
assert ( store . includes ( "replay_events" ), "event store must expose replay_events" );
assert ( store . includes ( "replay_requires_resync" ), "event store must detect incomplete replay windows" );
2026-05-27 11:03:04 +08:00
const redisStorePath = path . join ( repoRoot , "web" , "redis_realtime_event_store.py" );
assert ( fs . existsSync ( redisStorePath ), "backend must define a Redis Stream realtime event store" );
const redisStore = fs . readFileSync ( redisStorePath , "utf8" );
assert ( redisStore . includes ( "RedisRealtimeEventStore" ), "Redis store must expose RedisRealtimeEventStore" );
assert ( redisStore . includes ( "XADD" ) && redisStore . includes ( "MAXLEN" ), "Redis store must append patches to a bounded Redis Stream" );
assert ( redisStore . includes ( "xread" ), "Redis store must support live fanout through Redis Stream reads" );
assert ( redisStore . includes ( "counter:city_observation_revision" ), "Redis store must keep a numeric revision counter for frontend compatibility" );
const storeFactoryPath = path . join ( repoRoot , "web" , "realtime_event_store_factory.py" );
assert ( fs . existsSync ( storeFactoryPath ), "backend must define a realtime event store factory" );
const storeFactory = fs . readFileSync ( storeFactoryPath , "utf8" );
assert ( storeFactory . includes ( "POLYWEATHER_EVENT_STORE" ), "event store factory must select sqlite/redis from runtime config" );
assert ( storeFactory . includes ( "POLYWEATHER_REDIS_REQUIRED" ), "event store factory must support strict Redis mode" );
2026-05-26 10:39:17 +08:00
const sseRouterPath = path . join ( repoRoot , "web" , "routers" , "sse_router.py" );
assert ( fs . existsSync ( sseRouterPath ), "FastAPI backend must define web/routers/sse_router.py" );
const sseRouter = fs . readFileSync ( sseRouterPath , "utf8" );
assert ( sseRouter . includes ( '"/api/events"' ), "SSE router must expose GET /api/events" );
2026-05-26 22:27:55 +08:00
assert ( sseRouter . includes ( "cities" ), "SSE route must accept a cities query parameter" );
assert ( sseRouter . includes ( "since_revision" ), "SSE route must accept since_revision for replay" );
assert ( sseRouter . includes ( "replay_limit" ), "SSE route must bound replay batches" );
assert ( sseRouter . includes ( "resync_required" ), "SSE route must emit resync_required when replay is incomplete" );
2026-05-26 10:39:17 +08:00
assert ( sseRouter . includes ( '"/api/internal/collector-patch"' ), "SSE router must expose collector patch ingest endpoint" );
assert ( sseRouter . includes ( "StreamingResponse" ), "SSE route must return StreamingResponse" );
assert ( sseRouter . includes ( '"text/event-stream"' ), "SSE route must use text/event-stream media type" );
2026-05-27 11:03:04 +08:00
assert ( sseRouter . includes ( "create_realtime_event_store" ), "SSE router must use the realtime event store factory" );
assert ( sseRouter . includes ( "_ensure_live_subscription" ), "SSE router must start external live fanout when the store provides it" );
assert ( sseRouter . includes ( "uses_external_live_fanout" ), "Redis-backed ingest must not directly broadcast duplicate local events" );
2026-05-26 10:39:17 +08:00
const appFactory = readRepoFile ( "web" , "app_factory.py" );
assert ( appFactory . includes ( "sse_router" ), "FastAPI app factory must register the SSE router" );
const nginx = readRepoFile ( "deploy" , "nginx" , "polyweather.conf" );
assert ( nginx . includes ( "location /api/events" ), "Nginx deploy config must route /api/events separately" );
assert ( nginx . includes ( "proxy_buffering off" ), "Nginx /api/events must disable proxy buffering for SSE" );
assert ( nginx . includes ( "proxy_read_timeout 86400s" ), "Nginx /api/events must keep SSE connections open" );
const weatherSources = readRepoFile ( "src" , "data_collection" , "weather_sources.py" );
assert ( weatherSources . includes ( "_emit_temperature_patch_if_changed" ), "collector must centralize temperature patch emission" );
assert ( weatherSources . includes ( "requests.post" ), "collector must POST patches to the internal endpoint" );
assert ( weatherSources . includes ( "/api/internal/collector-patch" ), "collector must POST to /api/internal/collector-patch" );
assert ( weatherSources . includes ( "threading.Thread" ), "collector patch POST must run in a separate thread" );
const hookPath = path . join ( process . cwd (), "hooks" , "use-sse-patches.ts" );
assert ( fs . existsSync ( hookPath ), "frontend must define hooks/use-sse-patches.ts" );
const hook = fs . readFileSync ( hookPath , "utf8" );
assert ( hook . includes ( "new EventSource" ), "frontend patch hook must connect with EventSource" );
assert ( hook . includes ( "/api/events" ), "frontend patch hook must subscribe to /api/events" );
2026-05-26 22:27:55 +08:00
assert ( hook . includes ( "city_observation_patch.v1" ), "frontend patch hook must accept v1 observation patch events" );
assert ( hook . includes ( "subscribedCities" ), "frontend patch hook must track the visible city subscription set" );
assert ( hook . includes ( "since_revision" ), "frontend patch hook must reconnect with since_revision" );
assert ( hook . includes ( "resync_required" ), "frontend patch hook must react to server resync_required events" );
2026-05-31 20:33:04 +08:00
assert (
hook . includes ( "SSE_REPLAY_EVENTS_PER_CITY" ) &&
hook . includes ( "resolveSseReplayLimit" ) &&
hook . includes ( 'params.set("replay_limit", String(resolveSseReplayLimit(cities.length)))' ) &&
! hook . includes ( 'params.set("replay_limit", "500")' ),
"frontend patch hook should size SSE replay_limit by visible city count instead of always asking for 500 events" ,
);
2026-05-26 22:27:55 +08:00
assert ( hook . includes ( "lastRevision" ), "frontend patch hook must track the global last processed revision" );
2026-05-26 10:39:17 +08:00
assert ( hook . includes ( "Map<" ), "frontend patch hook must keep latest patches in a Map" );
assert ( hook . includes ( "useLatestPatch" ), "frontend patch hook must export useLatestPatch(city)" );
assert ( hook . includes ( "revision" ), "frontend patch hook must track revisions and skip stale patches" );
assert ( hook . includes ( "setTimeout" ), "frontend patch hook must implement explicit reconnect backoff" );
2026-05-31 20:15:39 +08:00
assert (
hook . includes ( "SSE_SUBSCRIPTION_RECONNECT_DELAY_MS" ) &&
hook . includes ( "scheduleSubscriptionReconnect" ) &&
hook . includes ( "clearSubscriptionReconnectTimer" ),
"frontend patch hook must debounce visible-city subscription changes before reopening SSE" ,
);
const subscriptionBlock = hook . match ( /function registerCitySubscription[\s\S]*?\n}\r?\n\r?\nfunction normalizeLegacyPatch/ ) ? .[ 0 ] || "" ;
assert (
subscriptionBlock . includes ( "scheduleSubscriptionReconnect()" ) &&
! subscriptionBlock . includes ( "ensureSsePatchConnection();" ),
"city subscription mount/unmount should schedule one coalesced SSE reconnect instead of reconnecting per chart" ,
);
2026-05-26 10:39:17 +08:00
2026-05-26 22:27:55 +08:00
const bffEventsRoute = readFrontendFile ( "app" , "api" , "events" , "route.ts" );
assert ( bffEventsRoute . includes ( "searchParams" ), "Next.js SSE proxy must forward query parameters to FastAPI" );
2026-05-26 10:39:17 +08:00
const chart = readFrontendFile ( "components" , "dashboard" , "scan-terminal" , "LiveTemperatureThresholdChart.tsx" );
2026-05-27 00:24:19 +08:00
const chartCanvasPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "TemperatureChartCanvas.tsx" );
const chartStatsPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "TemperatureStatsBars.tsx" );
const chartRunwayPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "TemperatureRunwayDetails.tsx" );
const chartTooltipPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "TemperatureTooltipContent.tsx" );
2026-05-27 01:16:49 +08:00
const chartSummaryPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "ModelCurvesSummary.tsx" );
2026-05-27 00:24:19 +08:00
assert ( fs . existsSync ( chartCanvasPath ), "temperature chart Recharts canvas must live in TemperatureChartCanvas.tsx" );
assert ( fs . existsSync ( chartStatsPath ), "temperature chart stat bars must live in TemperatureStatsBars.tsx" );
assert ( fs . existsSync ( chartRunwayPath ), "temperature chart runway detail panel must live in TemperatureRunwayDetails.tsx" );
assert ( fs . existsSync ( chartTooltipPath ), "temperature chart tooltip must live in TemperatureTooltipContent.tsx" );
2026-05-27 01:16:49 +08:00
assert ( fs . existsSync ( chartSummaryPath ), "temperature chart model summary must live in ModelCurvesSummary.tsx" );
2026-05-27 00:24:19 +08:00
const chartCanvas = fs . readFileSync ( chartCanvasPath , "utf8" );
const chartTooltip = fs . readFileSync ( chartTooltipPath , "utf8" );
2026-05-27 01:16:49 +08:00
const chartRunway = fs . readFileSync ( chartRunwayPath , "utf8" );
const chartSummary = fs . readFileSync ( chartSummaryPath , "utf8" );
2026-05-26 23:12:48 +08:00
const chartLogicPath = path . join ( process . cwd (), "components" , "dashboard" , "scan-terminal" , "temperature-chart-logic.ts" );
assert ( fs . existsSync ( chartLogicPath ), "temperature chart pure data logic must live in temperature-chart-logic.ts" );
const chartLogic = fs . readFileSync ( chartLogicPath , "utf8" );
assert ( chartLogic . includes ( "buildFullDayChartData" ), "temperature-chart-logic.ts must own full-day chart data generation" );
assert ( chartLogic . includes ( "mergePatchIntoHourly" ), "temperature-chart-logic.ts must own SSE patch merge logic" );
assert ( ! chart . includes ( "function buildFullDayChartData" ), "LiveTemperatureThresholdChart.tsx must not define full-day chart data generation inline" );
assert ( ! chart . includes ( "function mergePatchIntoHourly" ), "LiveTemperatureThresholdChart.tsx must not define SSE patch merge logic inline" );
2026-05-26 10:39:17 +08:00
assert ( chart . includes ( "useLatestPatch" ), "temperature chart must consume useLatestPatch(city)" );
assert ( chart . includes ( "latestPatch" ), "temperature chart must react to incoming SSE patches" );
2026-05-26 22:27:55 +08:00
assert ( chart . includes ( "useSseResyncVersion" ), "temperature chart must resync full detail when SSE replay is incomplete" );
2026-05-26 23:12:48 +08:00
assert ( chartLogic . includes ( "runway_points" ), "temperature chart must merge v1 runway_points into runway history" );
2026-05-26 10:39:17 +08:00
assert ( chart . includes ( "2 * 60_000" ), "temperature chart must wait two minutes without patches before full-fetch fallback" );
2026-05-27 00:24:19 +08:00
assert ( chart . includes ( "TemperatureChartCanvas" ), "temperature chart shell must compose the extracted chart canvas" );
assert ( chart . includes ( "TemperatureStatsBars" ), "temperature chart shell must compose the extracted stat bars" );
assert ( chart . includes ( "TemperatureRunwayDetails" ), "temperature chart shell must compose the extracted runway panel" );
2026-05-27 01:16:49 +08:00
assert ( chart . includes ( "tempSymbol={row?.temp_symbol || \"°C\"}" ), "temperature chart shell must pass the city temperature unit into stat bars" );
assert ( chart . includes ( "<TemperatureRunwayDetails" ) && chart . includes ( "tempSymbol={row?.temp_symbol || \"°C\"}" ), "temperature chart shell must pass the city unit into runway details" );
assert ( chart . includes ( "<ModelCurvesSummary" ) && chart . includes ( "tempSymbol={row?.temp_symbol || \"°C\"}" ), "temperature chart shell must pass the city unit into model summaries" );
const chartStats = fs . readFileSync ( chartStatsPath , "utf8" );
assert ( chartStats . includes ( "tempSymbol" ), "temperature stat bars must accept the city temperature unit" );
assert ( chartStats . includes ( "temp(displayRunwayTemp, tempSymbol)" ), "temperature stat bars must render live observations with the city unit" );
assert ( chartRunway . includes ( "tempSymbol" ) && ! chartRunway . includes ( "}°C`" ), "runway detail rows must render values with the city unit" );
assert ( chartSummary . includes ( "tempSymbol" ) && chartSummary . includes ( "temp(stats.latest, tempSymbol)" ), "model curve summaries must render values with the city unit" );
assert ( chartCanvas . includes ( "const tempSymbol = row?.temp_symbol || \"°C\"" ), "temperature chart canvas must derive the city unit from the row" );
assert ( chartCanvas . includes ( "tempSymbol={tempSymbol}" ), "temperature chart canvas must pass the city unit into tooltips" );
assert ( chartCanvas . includes ( "tickFormatter={(v) => `${Number(v).toFixed(0)}${tempSymbol}`}" ), "temperature y-axis ticks must include °C/°F instead of a bare degree mark" );
assert ( chartTooltip . includes ( "tempSymbol" ), "temperature tooltip must accept the city temperature unit" );
2026-05-27 00:24:19 +08:00
assert ( ! chart . includes ( "from \"recharts\"" ), "LiveTemperatureThresholdChart.tsx must not import Recharts directly" );
assert ( ! chart . includes ( "function TemperatureTooltipContent" ), "LiveTemperatureThresholdChart.tsx must not define tooltip content inline" );
assert ( chartCanvas . includes ( "TemperatureTooltipContent" ), "temperature chart canvas must use a custom tooltip content component" );
assert ( chartCanvas . includes ( "filterNull={false}" ), "temperature chart tooltip must keep null-slot payload so hover works between sparse points" );
assert ( chartTooltip . includes ( "nearestSeriesValue" ), "temperature chart tooltip must fall back to nearest non-null value for connected sparse lines" );
2026-05-26 23:12:48 +08:00
assert ( chart . includes ( "isHourlyLoading" ), "temperature chart must keep a per-panel hourly loading state" );
2026-05-27 00:24:19 +08:00
assert ( chartCanvas . includes ( "加载图表" ) && chartCanvas . includes ( "absolute inset-2" ), "temperature chart must render an in-chart loading overlay" );
2026-05-26 23:56:42 +08:00
assert ( chart . includes ( "hasLoadedHourlyDetailRef" ), "temperature chart must distinguish first load from background refreshes" );
2026-05-27 00:44:38 +08:00
assert ( chart . includes ( "currentCityLocalDate" ), "temperature chart must track the current city-local date while the page stays open" );
assert ( chart . includes ( "localDayRolloverFetchDateRef" ), "temperature chart must avoid duplicate midnight rollover detail fetches" );
assert (
chart . includes ( "ignoreCache: true" ) && chart . includes ( "currentCityLocalDate !== loadedLocalDate" ),
"temperature chart must background-refresh full city detail when the city-local day rolls over" ,
);
2026-05-26 23:56:42 +08:00
const fallbackRefreshBlock = chart . match ( /const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/ ) ? .[ 0 ] || "" ;
assert (
! fallbackRefreshBlock . includes ( "setIsHourlyLoading(true)" ),
"no-patch fallback refresh should update the chart in the background without showing the loading overlay" ,
);
const resyncBlock = chart . match ( /useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution\]\);/ ) ? .[ 0 ] || "" ;
assert (
! resyncBlock . includes ( "setIsHourlyLoading(true)" ),
"SSE replay resync should refresh full detail in the background without showing the loading overlay" ,
);
2026-05-27 10:17:33 +08:00
assert (
chart . includes ( "visibilitychange" ) &&
chart . includes ( 'document.visibilityState !== "visible"' ) &&
chart . includes ( "refreshForegroundFullDetail" ),
"temperature chart must immediately refresh visible charts when the browser tab returns to the foreground" ,
);
const foregroundRefreshBlock = chart . match ( /const refreshForegroundFullDetail = \(\) => \{[\s\S]*?\n \};/ ) ? .[ 0 ] || "" ;
assert (
foregroundRefreshBlock . includes ( "ignoreCache: true" ) &&
foregroundRefreshBlock . includes ( "fetchHourlyForecastForCity" ) &&
2026-05-31 22:14:17 +08:00
foregroundRefreshBlock . includes ( "FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS" ) &&
2026-05-27 10:17:33 +08:00
! foregroundRefreshBlock . includes ( "setIsHourlyLoading(true)" ),
2026-05-31 22:14:17 +08:00
"foreground resume refresh should update full detail in the background without showing the loading overlay or refetching fresh detail" ,
2026-05-27 10:17:33 +08:00
);
2026-05-31 18:28:50 +08:00
assert (
! chart . includes ( "/api/city/${encodeURIComponent(city)}/summary" ),
"visible chart fallback and foreground refresh should not issue a separate summary request after requesting full detail" ,
);
2026-05-26 23:12:48 +08:00
assert ( chart . includes ( "viewMode" ), "temperature chart must expose a view mode for DEB-peak auto view versus full-day view" );
2026-05-27 08:42:08 +08:00
assert ( chart . includes ( 'useState<"auto" | "full">("full")' ), "temperature chart must default every city panel to the all-day view" );
2026-05-27 08:53:36 +08:00
assert (
chart . includes ( 'setViewMode("full")' ) && ! chart . includes ( 'setViewMode("auto")' ),
"temperature chart must reset city changes to the all-day view instead of silently switching back to the DEB peak window" ,
);
2026-05-27 08:42:08 +08:00
assert ( chart . includes ( "getDebPeakWindowRange" ), "temperature chart must still derive the optional Peak view from the DEB peak window" );
2026-05-27 00:53:53 +08:00
assert (
chart . includes ( 'isEn ? "Peak" : "高温"' ) && chart . includes ( 'isEn ? "All Day" : "全天"' ),
"temperature chart view-mode labels must translate 高温/全天 as Peak/All Day" ,
);
assert (
! chart . includes ( 'isEn ? "Auto" : "高温"' ) && ! chart . includes ( 'isEn ? "Full" : "全天"' ),
"temperature chart view-mode labels must not expose internal Auto/Full wording" ,
);
2026-05-26 23:28:43 +08:00
assert ( chart . includes ( "nextTargetResolution" ), "temperature chart must derive target resolution without setting state on every render" );
assert (
chart . includes ( "targetResolution !== nextTargetResolution" ),
"temperature chart must guard target-resolution state updates to prevent render/update loops" ,
);
2026-05-27 08:53:36 +08:00
assert (
chart . includes ( "prefersHighFrequencyRunwayResolution" ) && chart . includes ( 'return "1m";' ),
"runway charts must request 1-minute detail resolution so historical runway lines match live SSE patch cadence" ,
);
2026-05-27 10:17:33 +08:00
assert (
chart . includes ( "PROBABILITY_REFRESH_AFTER_PATCH_MS" ) &&
chart . includes ( "lastProbabilityRefreshAtRef" ) &&
chart . includes ( "refreshProbabilityOverlayAfterPatch" ),
"temperature chart must trigger a throttled background probability refresh after live observation patches" ,
);
const patchEffectBlock = chart . match ( /useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?\}, \[latestPatch, row, city, targetResolution, compact, isActive, isMaximized\]\);/ ) ? .[ 0 ] || "" ;
assert (
patchEffectBlock . includes ( "refreshProbabilityOverlayAfterPatch" ) &&
patchEffectBlock . includes ( "ignoreCache: true" ) &&
! patchEffectBlock . includes ( "setIsHourlyLoading(true)" ),
"live patch probability refresh must recompute legacy Gaussian in the background without showing a loading overlay" ,
);
2026-05-27 00:24:19 +08:00
assert ( ! chartCanvas . includes ( "ResponsiveContainer" ), "temperature chart canvas must not mount Recharts through ResponsiveContainer at 0x0" );
assert ( chartCanvas . includes ( "ResizeObserver" ), "temperature chart canvas must measure its host with ResizeObserver" );
2026-05-26 23:28:43 +08:00
assert (
2026-05-27 00:24:19 +08:00
chartCanvas . includes ( "chartSize.width > 0" ) && chartCanvas . includes ( "chartSize.height > 0" ),
"temperature chart canvas must render Recharts only after positive host dimensions are measured" ,
);
assert (
chartCanvas . includes ( "width={chartWidth}" ) && chartCanvas . includes ( "height={chartHeight}" ),
"temperature chart canvas must pass explicit positive width/height to Recharts" ,
2026-05-26 23:28:43 +08:00
);
2026-05-27 08:53:36 +08:00
assert (
chartCanvas . includes ( "canToggleRunwayDetails" ) && chartCanvas . includes ( "individualRunwaySeriesCount > 1" ),
"single-runway charts must not show the runway-detail toggle because aggregate and individual views are visually redundant" ,
);
2026-05-30 16:39:23 +08:00
assert (
chartLogic . includes ( "HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 12_000" ) &&
chartLogic . includes ( "fetchCityDetailWithTimeout" ) &&
chartLogic . includes ( "signal: controller.signal" ) &&
chartLogic . includes ( "controller.abort()" ),
"city detail chart fetches must have a frontend timeout so panels cannot stay on 加载图表 forever" ,
);
2026-05-26 23:12:48 +08:00
assert ( ! chart . includes ( "3D" ), "temperature chart UI must not expose a 3D/future-forecast mode" );
assert ( ! chart . includes ( "build3DayChartData" ), "temperature chart component must not render future prediction curves" );
2026-05-26 10:39:17 +08:00
assert (
! chart . includes ( "setInterval(poll, 60_000)" ),
"temperature chart must not use unconditional 60-second full-detail polling after SSE patch migration" ,
);
}