2026-05-25 23:44:02 +08:00
import fs from "node:fs" ;
import path from "node:path" ;
2026-05-30 21:01:48 +08:00
import { __shouldKeepTemperatureChartLoadingForTest } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas" ;
2026-05-25 23:44:02 +08:00
function assert ( condition : unknown , message : string ) {
if ( ! condition ) throw new Error ( message );
}
export function runTests() {
const projectRoot = process . cwd ();
const dashboardSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "ScanTerminalDashboard.tsx" ),
"utf8" ,
);
const selectorSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "GridLayoutSelector.tsx" ),
"utf8" ,
);
2026-06-10 15:52:23 +08:00
const usageGuideSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "UsageGuideDashboard.tsx" ),
"utf8" ,
);
2026-05-27 08:07:49 +08:00
const chartSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "LiveTemperatureThresholdChart.tsx" ),
"utf8" ,
);
2026-05-31 01:56:08 +08:00
const terminalPageSource = fs . readFileSync (
path . join ( projectRoot , "app" , "terminal" , "page.tsx" ),
"utf8" ,
);
2026-05-28 09:07:09 +08:00
const chartCanvasSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "TemperatureChartCanvas.tsx" ),
"utf8" ,
);
2026-05-30 16:39:23 +08:00
const citySelectorSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "CitySelectorDropdown.tsx" ),
"utf8" ,
);
2026-05-31 01:56:08 +08:00
const scanQuerySource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "use-scan-terminal-query.ts" ),
"utf8" ,
);
2026-06-05 16:08:34 +08:00
const mobileRegionTabsSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "MobileRegionTabs.tsx" ),
"utf8" ,
);
2026-05-25 23:44:02 +08:00
assert (
2026-06-10 15:52:23 +08:00
dashboardSource . includes ( "MAX_TERMINAL_CHARTS = 6" ),
"terminal dashboard must cap the desktop homepage at 6 charts" ,
2026-05-25 23:44:02 +08:00
);
assert (
dashboardSource . includes ( "MOBILE_TERMINAL_CHARTS = 1" ),
"terminal dashboard must document that mobile renders exactly one chart" ,
);
assert (
2026-06-10 15:52:23 +08:00
dashboardSource . includes ( "MAX_TERMINAL_GRID_ROWS = 2" ) &&
dashboardSource . includes ( "clampGridRows" ) &&
2026-05-25 23:44:02 +08:00
dashboardSource . includes ( "Math.min(MAX_TERMINAL_CHARTS" ),
2026-06-10 15:52:23 +08:00
"terminal grid dimensions must clamp saved 3x3 layouts down to the 3x2 maximum before rendering or persisting" ,
2026-05-25 23:44:02 +08:00
);
assert (
dashboardSource . includes ( "mobileChartRow" ) &&
dashboardSource . includes ( "建议横屏" ) &&
dashboardSource . includes ( "Rotate to landscape" ) &&
dashboardSource . includes ( "disableClose={true}" ),
"mobile terminal should render one selected chart and suggest landscape for the full grid" ,
);
2026-06-05 16:08:34 +08:00
assert (
dashboardSource . includes ( "mobile-region-tabs-shell" ) &&
dashboardSource . includes ( "handleMobileSelectTab" ) &&
dashboardSource . includes ( "mobileSelectedRowInActiveGroup" ) &&
dashboardSource . includes ( "mobileActiveGroup?.rows[0]" ) &&
mobileRegionTabsSource . includes ( "min-h-11" ) &&
mobileRegionTabsSource . includes ( "snap-x" ) &&
mobileRegionTabsSource . includes ( "aria-pressed={isActive}" ),
"mobile region navigation must stay visible above the landscape hint, remain touch-friendly, and switch the visible chart with the active region" ,
);
2026-06-02 08:45:25 +08:00
assert (
! dashboardSource . includes ( "disableClose={visibleSlots.filter(Boolean).length <= 1}" ),
"desktop chart slots must allow clearing the last visible city so users can close a 1x1 chart" ,
);
assert (
dashboardSource . includes ( "readStoredTerminalSlots" ) &&
dashboardSource . includes ( "hasPersistedSlots" ) &&
dashboardSource . includes ( "shouldAutofillInitialSlots" ),
"terminal slots must distinguish first-load default fill from a user-cleared all-empty layout" ,
);
2026-05-25 23:44:02 +08:00
assert (
selectorSource . includes ( "[1, 2, 3].map" ) &&
2026-06-10 15:52:23 +08:00
selectorSource . includes ( "[1, 2].map" ) &&
selectorSource . includes ( "grid grid-cols-3" ) &&
! selectorSource . includes ( "[1, 2, 3].map((r)" ),
"grid selector must expose at most a 3 by 2 chart layout" ,
);
assert (
usageGuideSource . includes ( "1x1 到 3x2" ) &&
usageGuideSource . includes ( "1x1 to 3x2" ) &&
! usageGuideSource . includes ( "1x1 到 3x3" ) &&
! usageGuideSource . includes ( "1x1 to 3x3" ),
"terminal guide must describe the new 3x2 maximum layout instead of 3x3" ,
2026-05-25 23:44:02 +08:00
);
2026-05-27 07:50:05 +08:00
assert (
2026-06-16 04:29:24 +08:00
dashboardSource . includes ( "LoadingSlotCard" ) &&
dashboardSource . includes ( "cityInSlot && !rowForSlot && refreshing" ) &&
2026-05-27 07:50:05 +08:00
dashboardSource . includes ( "handleSelectCityForSlot(slotIndex, null);" ),
2026-06-16 04:29:24 +08:00
"terminal must show a loading chart card for saved cities while the first scan rows are still loading, then fall back to the empty picker for stale slots" ,
2026-05-27 07:50:05 +08:00
);
2026-05-30 16:39:23 +08:00
assert (
dashboardSource . includes ( "absolute left-1/2 top-12 z-50" ) &&
! dashboardSource . includes ( "top-1/2 -translate-x-1/2 -translate-y-1/2" ),
"empty top-row slots must open the city selector downward so the search input is not clipped by the viewport header" ,
);
assert (
citySelectorSource . includes ( "max-h-[calc(100vh-72px)]" ) &&
citySelectorSource . includes ( "min-h-0 flex-1 overflow-y-auto" ),
"city selector dropdown must cap its viewport height and keep the result list scrollable" ,
);
assert (
citySelectorSource . includes ( "MIN_DROPDOWN_TOP_PX" ) &&
citySelectorSource . includes ( "viewportNudgeY" ) &&
citySelectorSource . includes ( "getBoundingClientRect()" ),
"city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards" ,
);
2026-05-31 01:56:08 +08:00
assert (
terminalPageSource . includes ( "dynamic(" ) &&
terminalPageSource . includes ( 'import("@/components/dashboard/ScanTerminalDashboard")' ) &&
terminalPageSource . includes ( "DashboardShellSkeleton" ) &&
! terminalPageSource . includes ( 'import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";' ),
"terminal route must dynamically load the heavy dashboard behind the shell skeleton" ,
);
assert (
dashboardSource . includes ( 'from "next/dynamic"' ) &&
dashboardSource . includes ( 'import("@/components/dashboard/scan-terminal/TrainingDashboard")' ) &&
! dashboardSource . includes ( 'import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";' ),
"terminal dashboard must lazy-load the training analytics tab so Recharts stays out of the default terminal path" ,
);
assert (
2026-06-01 07:05:08 +08:00
chartSource . includes ( 'import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";' ) &&
! chartSource . includes ( "TemperatureChartCanvasFallback" ) &&
! chartSource . includes ( "import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")" ),
"terminal temperature charts must load the Recharts canvas with the terminal route so visible cards do not sit behind a second-stage fallback" ,
2026-05-31 01:56:08 +08:00
);
2026-05-27 08:07:49 +08:00
assert (
chartSource . includes ( "setLiveTemp(null);" ) &&
chartSource . includes ( "lastAppliedPatchRevisionRef.current = 0;" ),
"switching city slots must clear the previous live temperature so Fahrenheit values cannot leak into Celsius charts" ,
);
2026-05-28 09:07:09 +08:00
assert (
chartSource . includes ( 'compact ? "min-h-0" : "min-h-[300px]"' ) &&
chartCanvasSource . includes ( "const minChartHeight = compact ? 120 : 220" ) &&
chartCanvasSource . includes ( 'compact ? "min-h-[120px]" : "min-h-[220px]"' ),
2026-06-10 15:52:23 +08:00
"compact grid charts must not force desktop minimum heights that get clipped inside dense terminal slots" ,
2026-05-28 09:07:09 +08:00
);
2026-05-30 14:33:26 +08:00
const signedOutBlock = dashboardSource . slice (
dashboardSource . indexOf ( 'if (event === "SIGNED_OUT")' ),
dashboardSource . indexOf ( '} else if (event === "TOKEN_REFRESHED"' ),
);
assert (
signedOutBlock . includes ( "await supabase.auth.getSession()" ) &&
signedOutBlock . includes ( "mergeAccessStateWithAuthPayload(prev, payload)" ) &&
signedOutBlock . indexOf ( "await supabase.auth.getSession()" ) <
signedOutBlock . indexOf ( "setProAccess(createEmptyAccess(false))" ),
"terminal auth listener must re-check the current Supabase session before clearing access on SIGNED_OUT events" ,
);
2026-05-30 14:48:09 +08:00
assert (
dashboardSource . includes ( 'event === "INITIAL_SESSION"' ) &&
dashboardSource . indexOf ( 'event === "INITIAL_SESSION"' ) <
dashboardSource . indexOf ( 'event === "TOKEN_REFRESHED"' ),
"terminal auth listener must hydrate access from Supabase INITIAL_SESSION events during first navigation from the landing page" ,
);
2026-05-30 19:50:50 +08:00
assert (
dashboardSource . includes ( "useDeferredValue" ) &&
dashboardSource . includes ( "deferredSearchQuery" ) &&
dashboardSource . includes ( "[rows, deferredSearchQuery]" ),
"terminal search must defer expensive row filtering so typing stays responsive" ,
);
2026-05-31 01:56:08 +08:00
assert (
scanQuerySource . includes ( "MAX_STALE_SCAN_CACHE_MS" ) &&
scanQuerySource . includes ( "allowStale" ) &&
scanQuerySource . includes ( "setCachedRows(readScanCache(tradingRegion || \"\", { allowStale: true }))" ),
"terminal data hook must render stale scan rows immediately while revalidating the first-screen API" ,
);
assert (
citySelectorSource . includes ( "useDeferredValue" ) &&
citySelectorSource . includes ( "deferredSearchQuery" ) &&
citySelectorSource . includes ( "[rows, deferredSearchQuery, activeTab]" ),
"city selector search must defer expensive dropdown filtering so top-row selection stays responsive" ,
);
assert (
dashboardSource . includes ( "accessDecisionPending" ) &&
dashboardSource . includes ( "shouldShowPaywall" ) &&
dashboardSource . indexOf ( "if (accessDecisionPending)" ) <
dashboardSource . indexOf ( "if (shouldShowPaywall)" ),
"terminal must keep showing verification while access is undecided instead of flashing the paywall" ,
);
2026-05-30 19:50:50 +08:00
assert (
dashboardSource . includes ( 'trackAppEvent("enter_terminal"' ) &&
dashboardSource . includes ( 'entry: "terminal"' ),
"terminal must emit enter_terminal when an entitled user reaches the dashboard" ,
);
assert (
chartCanvasSource . includes ( "memo(" ) &&
chartCanvasSource . includes ( "TemperatureChartCanvasComponent" ),
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts" ,
);
2026-06-01 16:15:20 +08:00
assert (
dashboardSource . includes ( "const TerminalSidebar = memo(function TerminalSidebar" ) &&
dashboardSource . includes ( "const [navExpanded, setNavExpanded] = useState(false);" ) &&
dashboardSource . includes ( "transition-colors duration-150" ) &&
! dashboardSource . includes ( "transition-all duration-200" ),
"terminal sidebar expand state must be isolated from the chart grid and must not animate width through transition-all" ,
);
2026-05-30 21:01:48 +08:00
assert (
__shouldKeepTemperatureChartLoadingForTest ({
2026-05-30 22:37:08 +08:00
row : { city : "Moscow" } as any ,
isHourlyLoading : true ,
activeSeries : [],
probabilityOverlay : null ,
zoomedData : [
{ label : "00:00" , ts : 1 },
{ label : "05:00" , ts : 2 },
],
}),
"temperature chart should show the loading skeleton while the first detail fetch is in flight and no drawable data exists" ,
);
2026-06-16 04:29:24 +08:00
assert (
chartCanvasSource . includes ( "animate-pulse" ) &&
chartCanvasSource . includes ( "Loading chart" ) &&
chartCanvasSource . includes ( "bg-gradient-to-r" ),
"temperature chart loading skeleton must include visible animation on cold first-open charts" ,
);
2026-05-30 22:37:08 +08:00
assert (
! __shouldKeepTemperatureChartLoadingForTest ({
2026-05-30 21:01:48 +08:00
row : { city : "Moscow" } as any ,
isHourlyLoading : false ,
activeSeries : [],
probabilityOverlay : null ,
zoomedData : [
{ label : "00:00" , ts : 1 },
{ label : "05:00" , ts : 2 },
],
}),
2026-05-30 22:37:08 +08:00
"temperature chart must stop showing an indefinite loading overlay after the detail fetch finishes without drawable data" ,
2026-05-30 21:01:48 +08:00
);
assert (
! __shouldKeepTemperatureChartLoadingForTest ({
row : { city : "Moscow" } as any ,
isHourlyLoading : false ,
activeSeries : [
{
key : "current" ,
label : "Current reference" ,
source : "Live" ,
color : "#009688" ,
values : [ 13 , 13 ],
},
] as any ,
probabilityOverlay : null ,
zoomedData : [
{ label : "00:00" , ts : 1 , current : 13 },
{ label : "05:00" , ts : 2 , current : 13 },
],
}),
"temperature chart should render once a visible series has drawable values" ,
);
2026-05-30 22:37:08 +08:00
assert (
2026-06-16 05:43:19 +08:00
__shouldKeepTemperatureChartLoadingForTest ({
2026-05-30 22:37:08 +08:00
row : { city : "Moscow" } as any ,
isHourlyLoading : true ,
activeSeries : [
{
key : "current" ,
label : "Current reference" ,
source : "Live" ,
color : "#009688" ,
values : [ 13 , 13 ],
},
] as any ,
probabilityOverlay : null ,
zoomedData : [
{ label : "00:00" , ts : 1 , current : 13 },
{ label : "05:00" , ts : 2 , current : 13 },
],
}),
2026-06-16 05:43:19 +08:00
"temperature chart should keep the loading overlay when the only drawable series is the fallback Current reference line" ,
2026-05-30 22:37:08 +08:00
);
2026-05-25 23:44:02 +08:00
}