diff --git a/apps/azure/src/components/editor/InvestigationWorkspace.tsx b/apps/azure/src/components/editor/InvestigationWorkspace.tsx index efcf036fc..9e15764af 100644 --- a/apps/azure/src/components/editor/InvestigationWorkspace.tsx +++ b/apps/azure/src/components/editor/InvestigationWorkspace.tsx @@ -36,7 +36,7 @@ import type { Question, } from '@variscout/core'; import { - DEFAULT_PROCESS_HUB_ID, + normalizeProcessHubId, hasTeamFeatures, inferCharacteristicType, computeMainEffects, @@ -171,7 +171,7 @@ export const InvestigationWorkspace: React.FC = ({ const setWallViewMode = useCanvasViewportStore(s => s.setViewMode); // Phase 13 scale features — threaded into WallCanvas so zoom, pan, and // tributary clustering route through the existing store + persistence. - const wallHubId = processContext?.processHubId ?? DEFAULT_PROCESS_HUB_ID; + const wallHubId = normalizeProcessHubId(processContext?.processHubId); const wallZoom = useCanvasViewportStore(s => s.viewports[wallHubId]?.zoom ?? 1); const wallPan = useCanvasViewportStore(s => s.viewports[wallHubId]?.pan ?? DEFAULT_WALL_PAN); const setWallPan = useCanvasViewportStore(s => s.setPan); diff --git a/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.blob.test.ts b/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.blob.test.ts index 7b359de65..a16559ce4 100644 --- a/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.blob.test.ts +++ b/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.blob.test.ts @@ -27,6 +27,7 @@ import { rehydrateCanvasViewport, useCanvasViewportStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { loadBlobCanvasViewport, saveBlobCanvasViewport } from '../../../services/blobClient'; import type { LoadedViewport } from '../../../services/blobClient'; import { safeTrackEvent } from '../../../lib/appInsights'; @@ -39,7 +40,7 @@ const mockLoadBlob = vi.mocked(loadBlobCanvasViewport); const mockSaveBlob = vi.mocked(saveBlobCanvasViewport); const mockTrackEvent = vi.mocked(safeTrackEvent); -const HUB_ID = 'hub-blob-test'; +const HUB_ID = 'hub-blob-test' as ProcessHubId; const BLOB_SNAPSHOT: LoadedViewport['snapshot'] = { zoom: 2, diff --git a/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts b/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts index 5802d7820..d11dcf357 100644 --- a/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts +++ b/apps/azure/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts @@ -17,8 +17,11 @@ import { rehydrateCanvasViewport, useCanvasViewportStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useCanvasViewportLifecycle } from '../useCanvasViewportLifecycle'; +const h = (id: string) => id as ProcessHubId; + const mockPersist = vi.mocked(persistCanvasViewport); const mockRehydrate = vi.mocked(rehydrateCanvasViewport); @@ -52,7 +55,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => { viewports: { ...s.viewports, [hubId]: { - ...s.getViewport(hubId), + ...s.getViewport(h(hubId)), zoom: 2, }, }, @@ -83,7 +86,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => { expect(useCanvasViewportStore.getState().viewMode).toBe('map'); expect(useCanvasViewportStore.getState().railOpen).toBe(true); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1); + expect(useCanvasViewportStore.getState().getViewport(h('hub-A')).zoom).toBe(1); expect(mockPersist).not.toHaveBeenCalled(); }); @@ -121,9 +124,9 @@ describe('useCanvasViewportLifecycle (Azure)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-A', 2); - useCanvasViewportStore.getState().setPan('hub-A', { x: 10, y: 20 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); + useCanvasViewportStore.getState().setZoom(h('hub-A'), 2); + useCanvasViewportStore.getState().setPan(h('hub-A'), { x: 10, y: 20 }); + useCanvasViewportStore.getState().setGroupByTributary(h('hub-A'), true); }); act(() => { @@ -139,8 +142,8 @@ describe('useCanvasViewportLifecycle (Azure)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-B', 3); - useCanvasViewportStore.getState().setPan('hub-B', { x: -10, y: -20 }); + useCanvasViewportStore.getState().setZoom(h('hub-B'), 3); + useCanvasViewportStore.getState().setPan(h('hub-B'), { x: -10, y: -20 }); vi.advanceTimersByTime(500); }); @@ -152,7 +155,7 @@ describe('useCanvasViewportLifecycle (Azure)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-A', 5); + useCanvasViewportStore.getState().setZoom(h('hub-A'), 5); }); unmount(); diff --git a/apps/azure/src/features/investigation/useCanvasViewportLifecycle.ts b/apps/azure/src/features/investigation/useCanvasViewportLifecycle.ts index b6b9b8184..d9e4f8748 100644 --- a/apps/azure/src/features/investigation/useCanvasViewportLifecycle.ts +++ b/apps/azure/src/features/investigation/useCanvasViewportLifecycle.ts @@ -5,6 +5,7 @@ import { rehydrateCanvasViewport, useCanvasViewportStore, } from '@variscout/stores'; +import { normalizeProcessHubId } from '@variscout/core'; import { safeTrackEvent } from '../../lib/appInsights'; import { loadBlobCanvasViewport, saveBlobCanvasViewport } from '../../services/blobClient'; import type { ViewportBlobShape } from '../../services/blobClient'; @@ -30,21 +31,22 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo useEffect(() => { if (!hubId) return; + const boundHubId = normalizeProcessHubId(hubId); let timer: ReturnType | undefined; let cancelled = false; // ── Mount: Dexie cache (instant) then Blob reconcile ────────────────── - rehydrateCanvasViewport(hubId, () => !cancelled).catch(() => undefined); + rehydrateCanvasViewport(boundHubId, () => !cancelled).catch(() => undefined); void (async () => { // Read local updatedAt before the async Blob fetch so we compare // against the version already loaded by rehydrateCanvasViewport. - const localUpdatedAt = await getLocalViewportUpdatedAt(hubId); + const localUpdatedAt = await getLocalViewportUpdatedAt(boundHubId); if (cancelled) return; - const loaded = await loadBlobCanvasViewport(hubId); + const loaded = await loadBlobCanvasViewport(boundHubId); if (cancelled) return; if (!loaded) return; @@ -56,11 +58,11 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo useCanvasViewportStore.setState(s => ({ viewports: { ...s.viewports, - [hubId]: { zoom, pan, currentLevel, focalStepId, nodePositions, groupByTributary }, + [boundHubId]: { zoom, pan, currentLevel, focalStepId, nodePositions, groupByTributary }, }, })); // Write back to Dexie so the next offline session gets this state. - await persistCanvasViewport(hubId).catch(() => undefined); + await persistCanvasViewport(boundHubId).catch(() => undefined); } })(); @@ -69,7 +71,7 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo const changed = state.viewMode !== prev.viewMode || state.railOpen !== prev.railOpen || - state.viewports[hubId] !== prev.viewports[hubId]; + state.viewports[boundHubId] !== prev.viewports[boundHubId]; if (!changed) return; if (timer !== undefined) clearTimeout(timer); @@ -77,11 +79,11 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo if (cancelled) return; // Dexie persist (existing). - persistCanvasViewport(hubId).catch(() => undefined); + persistCanvasViewport(boundHubId).catch(() => undefined); // Build Blob snapshot from current store state. const current = useCanvasViewportStore.getState(); - const vp = current.viewports[hubId]; + const vp = current.viewports[boundHubId]; if (!vp) return; // hub not in store yet; skip blob write const snapshot: ViewportBlobShape = { @@ -94,7 +96,7 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo updatedAt: Date.now(), }; - void saveBlobCanvasViewport(hubId, snapshot, etagRef.current).then(result => { + void saveBlobCanvasViewport(boundHubId, snapshot, etagRef.current).then(result => { if (cancelled) return; if (result.ok) { @@ -109,11 +111,11 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo }); // Re-fetch Blob; apply if newer than our last write. - void loadBlobCanvasViewport(hubId).then(loaded => { + void loadBlobCanvasViewport(boundHubId).then(loaded => { if (cancelled || !loaded) return; etagRef.current = loaded.etag; - const currentVp = useCanvasViewportStore.getState().viewports[hubId]; + const currentVp = useCanvasViewportStore.getState().viewports[boundHubId]; if (!currentVp) return; // last-write-wins: blob wins the conflict; apply to store. @@ -122,7 +124,7 @@ export function useCanvasViewportLifecycle(hubId: string | null | undefined): vo useCanvasViewportStore.setState(s => ({ viewports: { ...s.viewports, - [hubId]: { + [boundHubId]: { zoom, pan, currentLevel, diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 327da81cc..c34b1502b 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -61,6 +61,7 @@ import { useEmbedMessaging } from './hooks/useEmbedMessaging'; import { SAMPLES } from '@variscout/data'; import { DEFAULT_PROCESS_HUB_ID, + normalizeProcessHubId, type ExclusionReason, type Question, toNumericValue, @@ -1072,7 +1073,7 @@ function AppMain() { /> ) : panels.activeView === 'investigation' ? ( ({ + default: () =>
Dashboard
, +})); +vi.mock('../components/views/FrameView', () => ({ + default: () =>
FrameView
, +})); +vi.mock('../components/views/InvestigationView', () => ({ + default: () =>
InvestigationView
, +})); +vi.mock('../components/views/ImprovementView', () => ({ + default: () =>
ImprovementView
, +})); +vi.mock('../components/views/ReportView', () => ({ + default: () =>
ReportView
, +})); +vi.mock('../components/ProcessIntelligencePanel', () => ({ + default: () =>
PI Panel
, +})); +vi.mock('../components/YamazumiDashboard', () => ({ + default: () =>
Yamazumi
, +})); +vi.mock('../components/WhatIfPage', () => ({ + default: () =>
What-If
, +})); +vi.mock('../components/settings/SettingsPanel', () => ({ + default: () =>
Settings
, +})); +vi.mock('../components/data/DataTableModal', () => ({ + default: () =>
Data Table
, +})); +vi.mock('../components/FindingsPanel', () => ({ + default: () =>
Findings
, +})); +vi.mock('../workers/useStatsWorker', () => ({ + useStatsWorker: () => null, +})); + +import { render, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import App from '../App'; +import { LocaleProvider } from '../context/LocaleContext'; +import { registerLocaleLoaders, type MessageCatalog } from '@variscout/core'; +import { usePanelsStore, initialPanelsState } from '../features/panels/panelsStore'; + +// Register locale loaders (mirrors main.tsx) so useTranslation works. +registerLocaleLoaders( + import.meta.glob>( + '../../../../packages/core/src/i18n/messages/*.ts', + { eager: false } + ) +); + +describe('setState-in-render regression — useAppPanels individual selectors', () => { + let consoleError: ReturnType; + + beforeEach(() => { + // Reset panels store so test isolation is guaranteed. + usePanelsStore.setState(initialPanelsState); + // Spy BEFORE render so any synchronous warning during mount is captured. + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('mounting App does not produce a setState-in-render warning', async () => { + await act(async () => { + render( + + + + ); + }); + + const warningCalls = consoleError.mock.calls.filter( + (args: unknown[]) => + typeof args[0] === 'string' && /Cannot update a component.*while rendering/i.test(args[0]) + ); + + expect(warningCalls).toHaveLength(0); + }); + + it('calling showFrame() after mount does not produce a setState-in-render warning', async () => { + await act(async () => { + render( + + + + ); + }); + + // Reset spy counts after mount so we only capture the showFrame transition. + consoleError.mockClear(); + + await act(async () => { + usePanelsStore.getState().showFrame(); + }); + + const warningCalls = consoleError.mock.calls.filter( + (args: unknown[]) => + typeof args[0] === 'string' && /Cannot update a component.*while rendering/i.test(args[0]) + ); + + expect(warningCalls).toHaveLength(0); + }); +}); diff --git a/apps/pwa/src/components/views/InvestigationView.tsx b/apps/pwa/src/components/views/InvestigationView.tsx index c42e124fa..8b6f0bc85 100644 --- a/apps/pwa/src/components/views/InvestigationView.tsx +++ b/apps/pwa/src/components/views/InvestigationView.tsx @@ -37,6 +37,7 @@ import type { ColumnTypeMap } from '@variscout/core/findings'; import type { DrillStep } from '@variscout/hooks'; import { GripVertical } from 'lucide-react'; import { useCanvasViewportStore, useProjectStore, useInvestigationStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useFindingsStore } from '../../features/findings/findingsStore'; import { useInvestigationFeatureStore, @@ -47,7 +48,7 @@ import { usePanelsStore } from '../../features/panels/panelsStore'; const DEFAULT_WALL_PAN = { x: 0, y: 0 }; interface InvestigationViewProps { - canvasViewportHubId: string; + canvasViewportHubId: ProcessHubId; // Data context filteredData: Record[]; outcome: string | null; diff --git a/apps/pwa/src/components/views/__tests__/InvestigationView.mapwall.test.tsx b/apps/pwa/src/components/views/__tests__/InvestigationView.mapwall.test.tsx index b969a5307..4aefac359 100644 --- a/apps/pwa/src/components/views/__tests__/InvestigationView.mapwall.test.tsx +++ b/apps/pwa/src/components/views/__tests__/InvestigationView.mapwall.test.tsx @@ -91,9 +91,12 @@ import { useProjectStore, } from '@variscout/stores'; import { DEFAULT_PROCESS_HUB_ID } from '@variscout/core'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { RETURN_NAVIGATION_STORAGE_KEY } from '@variscout/hooks'; import InvestigationView from '../InvestigationView'; +const h = (id: string) => id as ProcessHubId; + // ── 3. Minimal props factory ─────────────────────────────────────────────── function makeMinimalProps( @@ -101,7 +104,7 @@ function makeMinimalProps( ): React.ComponentProps { const noOp = vi.fn(); return { - canvasViewportHubId: 'hub-test', + canvasViewportHubId: h('hub-test'), filteredData: [], outcome: null, factors: [], @@ -193,13 +196,15 @@ describe('PWA InvestigationView Map/Wall toggle', () => { }, }); - render(); + render( + + ); const groupByTributary = screen.getByRole('button', { name: /group by tributary/i }); fireEvent.click(groupByTributary); const state = useCanvasViewportStore.getState(); - expect(state.viewports['session-hub-1']?.groupByTributary).toBe(true); + expect(state.viewports[h('session-hub-1')]?.groupByTributary).toBe(true); expect(state.viewports[DEFAULT_PROCESS_HUB_ID]).toBeUndefined(); }); diff --git a/apps/pwa/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts b/apps/pwa/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts index 68093be01..fe53a372b 100644 --- a/apps/pwa/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts +++ b/apps/pwa/src/features/investigation/__tests__/useCanvasViewportLifecycle.test.ts @@ -17,8 +17,11 @@ import { rehydrateCanvasViewport, useCanvasViewportStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useCanvasViewportLifecycle } from '../useCanvasViewportLifecycle'; +const h = (id: string) => id as ProcessHubId; + const mockPersist = vi.mocked(persistCanvasViewport); const mockRehydrate = vi.mocked(rehydrateCanvasViewport); @@ -64,7 +67,7 @@ describe('useCanvasViewportLifecycle (PWA)', () => { viewports: { ...s.viewports, [hubId]: { - ...s.getViewport(hubId), + ...s.getViewport(h(hubId)), zoom: 2, }, }, @@ -95,7 +98,7 @@ describe('useCanvasViewportLifecycle (PWA)', () => { expect(useCanvasViewportStore.getState().viewMode).toBe('map'); expect(useCanvasViewportStore.getState().railOpen).toBe(true); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1); + expect(useCanvasViewportStore.getState().getViewport(h('hub-A')).zoom).toBe(1); expect(mockPersist).not.toHaveBeenCalled(); }); @@ -104,9 +107,9 @@ describe('useCanvasViewportLifecycle (PWA)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-A', 1.5); - useCanvasViewportStore.getState().setPan('hub-A', { x: 12, y: -8 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); + useCanvasViewportStore.getState().setZoom(h('hub-A'), 1.5); + useCanvasViewportStore.getState().setPan(h('hub-A'), { x: 12, y: -8 }); + useCanvasViewportStore.getState().setGroupByTributary(h('hub-A'), true); }); expect(mockPersist).not.toHaveBeenCalled(); @@ -143,8 +146,8 @@ describe('useCanvasViewportLifecycle (PWA)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-B', 2); - useCanvasViewportStore.getState().setPan('hub-B', { x: 20, y: 30 }); + useCanvasViewportStore.getState().setZoom(h('hub-B'), 2); + useCanvasViewportStore.getState().setPan(h('hub-B'), { x: 20, y: 30 }); vi.advanceTimersByTime(500); }); @@ -156,7 +159,7 @@ describe('useCanvasViewportLifecycle (PWA)', () => { mockPersist.mockClear(); act(() => { - useCanvasViewportStore.getState().setZoom('hub-A', 2); + useCanvasViewportStore.getState().setZoom(h('hub-A'), 2); }); unmount(); diff --git a/apps/pwa/src/features/investigation/useCanvasViewportLifecycle.ts b/apps/pwa/src/features/investigation/useCanvasViewportLifecycle.ts index fd6b6e77d..d0c658b1d 100644 --- a/apps/pwa/src/features/investigation/useCanvasViewportLifecycle.ts +++ b/apps/pwa/src/features/investigation/useCanvasViewportLifecycle.ts @@ -4,27 +4,29 @@ import { rehydrateCanvasViewport, useCanvasViewportStore, } from '@variscout/stores'; +import { normalizeProcessHubId } from '@variscout/core'; export function useCanvasViewportLifecycle(hubId: string | null | undefined): void { useEffect(() => { if (!hubId) return; + const boundHubId = normalizeProcessHubId(hubId); let timer: ReturnType | undefined; let cancelled = false; - rehydrateCanvasViewport(hubId, () => !cancelled).catch(() => undefined); + rehydrateCanvasViewport(boundHubId, () => !cancelled).catch(() => undefined); const unsubscribe = useCanvasViewportStore.subscribe((state, prev) => { const changed = state.viewMode !== prev.viewMode || state.railOpen !== prev.railOpen || - state.viewports[hubId] !== prev.viewports[hubId]; + state.viewports[boundHubId] !== prev.viewports[boundHubId]; if (!changed) return; if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { if (!cancelled) { - persistCanvasViewport(hubId).catch(() => undefined); + persistCanvasViewport(boundHubId).catch(() => undefined); } }, 500); }); diff --git a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts index 9f0126374..3dcc4cbbb 100644 --- a/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts +++ b/apps/pwa/src/features/panels/__tests__/panelsStore.test.ts @@ -63,6 +63,22 @@ describe('panelsStore', () => { expect(s.isDataTableOpen).toBe(true); expect(s.showExcludedOnly).toBe(true); }); + + it('openDataTableAtRow(5, true) sets highlightRowIndex and opens PI sidebar (desktop)', () => { + usePanelsStore.getState().openDataTableAtRow(5, true); + const s = usePanelsStore.getState(); + expect(s.highlightRowIndex).toBe(5); + expect(s.isPISidebarOpen).toBe(true); + expect(s.isDataTableOpen).toBe(false); + }); + + it('openDataTableAtRow(5, false) sets highlightRowIndex and opens data table modal (mobile)', () => { + usePanelsStore.getState().openDataTableAtRow(5, false); + const s = usePanelsStore.getState(); + expect(s.highlightRowIndex).toBe(5); + expect(s.isDataTableOpen).toBe(true); + expect(s.isPISidebarOpen).toBe(false); + }); }); describe('workspace navigation', () => { diff --git a/apps/pwa/src/features/panels/panelsStore.ts b/apps/pwa/src/features/panels/panelsStore.ts index 2db3b63c2..aa48dee2f 100644 --- a/apps/pwa/src/features/panels/panelsStore.ts +++ b/apps/pwa/src/features/panels/panelsStore.ts @@ -65,6 +65,7 @@ interface PanelsActions { closeDataTable: () => void; openDataTableExcluded: () => void; openDataTableAll: () => void; + openDataTableAtRow: (index: number, isDesktop: boolean) => void; // PWA-specific setShowExcludedOnly: (v: boolean) => void; @@ -146,6 +147,12 @@ export const usePanelsStore = create(set => ({ openDataTableAll: () => set({ showExcludedOnly: false, highlightRowIndex: null, isDataTableOpen: true }), + // Compound: open data table at specific row — desktop uses PI sidebar, mobile uses modal + openDataTableAtRow: (index, isDesktop) => + isDesktop + ? set({ highlightRowIndex: index, isPISidebarOpen: true }) + : set({ highlightRowIndex: index, isDataTableOpen: true }), + // PWA-specific setShowExcludedOnly: v => set({ showExcludedOnly: v }), setShowResetConfirm: v => set({ showResetConfirm: v }), diff --git a/apps/pwa/src/hooks/useAppPanels.ts b/apps/pwa/src/hooks/useAppPanels.ts index 945cd1b59..71dc2f3ff 100644 --- a/apps/pwa/src/hooks/useAppPanels.ts +++ b/apps/pwa/src/hooks/useAppPanels.ts @@ -62,17 +62,57 @@ export interface UseAppPanelsReturn { } /** - * Panel orchestration hook — now backed by Zustand store. + * Panel orchestration hook — backed by Zustand store. * * Maintains the same return interface as the original useReducer version * so App.tsx doesn't need to change. Side effects (keyboard, auto-clear, * resize) stay here since Zustand stores are pure state. + * + * Uses individual field selectors (never bare usePanelsStore()) to prevent + * whole-store subscriptions from causing unnecessary re-renders and to + * avoid React 19 "setState-in-render" warnings triggered by store-snapshot + * tearing detection in concurrent / Strict Mode. */ export function useAppPanels(options: UseAppPanelsOptions): UseAppPanelsReturn { const { clearData, wideFormatDetection, dismissWideFormat } = options; - // Read from Zustand store - const store = usePanelsStore(); + // ── State fields (individual selectors — never bare usePanelsStore()) ── + const activeView = usePanelsStore(s => s.activeView); + const isSettingsOpen = usePanelsStore(s => s.isSettingsOpen); + const isDataTableOpen = usePanelsStore(s => s.isDataTableOpen); + const isFindingsOpen = usePanelsStore(s => s.isFindingsOpen); + const highlightRowIndex = usePanelsStore(s => s.highlightRowIndex); + const showExcludedOnly = usePanelsStore(s => s.showExcludedOnly); + const showResetConfirm = usePanelsStore(s => s.showResetConfirm); + const isWhatIfOpen = usePanelsStore(s => s.isWhatIfOpen); + const highlightedChartPoint = usePanelsStore(s => s.highlightedChartPoint); + const isPISidebarOpen = usePanelsStore(s => s.isPISidebarOpen); + const openSpecEditorRequested = usePanelsStore(s => s.openSpecEditorRequested); + const sustainmentTargetId = usePanelsStore(s => s.sustainmentTargetId); + const handoffTargetId = usePanelsStore(s => s.handoffTargetId); + + // ── Action selectors (stable function references from the store) ────── + const showFrame = usePanelsStore(s => s.showFrame); + const showAnalysis = usePanelsStore(s => s.showAnalysis); + const showInvestigation = usePanelsStore(s => s.showInvestigation); + const showImprovement = usePanelsStore(s => s.showImprovement); + const showReport = usePanelsStore(s => s.showReport); + const setSettingsOpen = usePanelsStore(s => s.setSettingsOpen); + const setDataTableOpen = usePanelsStore(s => s.setDataTableOpen); + const setFindingsOpen = usePanelsStore(s => s.setFindingsOpen); + const toggleFindings = usePanelsStore(s => s.toggleFindings); + const setWhatIfOpen = usePanelsStore(s => s.setWhatIfOpen); + const togglePISidebar = usePanelsStore(s => s.togglePISidebar); + const setHighlightRow = usePanelsStore(s => s.setHighlightRow); + const setHighlightPoint = usePanelsStore(s => s.setHighlightPoint); + const setShowExcludedOnly = usePanelsStore(s => s.setShowExcludedOnly); + const setShowResetConfirm = usePanelsStore(s => s.setShowResetConfirm); + const setOpenSpecEditorRequested = usePanelsStore(s => s.setOpenSpecEditorRequested); + const confirmReset = usePanelsStore(s => s.confirmReset); + const closeDataTable = usePanelsStore(s => s.closeDataTable); + const openDataTableExcluded = usePanelsStore(s => s.openDataTableExcluded); + const openDataTableAll = usePanelsStore(s => s.openDataTableAll); + const openDataTableAtRowAction = usePanelsStore(s => s.openDataTableAtRow); const [isDesktop, setIsDesktop] = useState( typeof window !== 'undefined' && window.innerWidth >= DESKTOP_BREAKPOINT @@ -91,9 +131,9 @@ export function useAppPanels(options: UseAppPanelsOptions): UseAppPanelsReturn { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (wideFormatDetection) dismissWideFormat(); - else if (store.showResetConfirm) store.setShowResetConfirm(false); - else if (store.isSettingsOpen) store.setSettingsOpen(false); - else if (store.isDataTableOpen) store.setDataTableOpen(false); + else if (showResetConfirm) setShowResetConfirm(false); + else if (isSettingsOpen) setSettingsOpen(false); + else if (isDataTableOpen) setDataTableOpen(false); } }; @@ -101,82 +141,80 @@ export function useAppPanels(options: UseAppPanelsOptions): UseAppPanelsReturn { return () => window.removeEventListener('keydown', handleKeyDown); }, [ wideFormatDetection, - store.showResetConfirm, - store.isSettingsOpen, - store.isDataTableOpen, + showResetConfirm, + isSettingsOpen, + isDataTableOpen, dismissWideFormat, - store, + setShowResetConfirm, + setSettingsOpen, + setDataTableOpen, ]); // Auto-clear highlighted chart point after 2 seconds useEffect(() => { - if (store.highlightedChartPoint === null) return; - const timer = setTimeout(() => store.setHighlightPoint(null), 2000); + if (highlightedChartPoint === null) return; + const timer = setTimeout(() => setHighlightPoint(null), 2000); return () => clearTimeout(timer); - }, [store.highlightedChartPoint, store]); + }, [highlightedChartPoint, setHighlightPoint]); // Compound actions that need isDesktop const openDataTableAtRow = useCallback( (index: number) => { - if (isDesktop) { - usePanelsStore.setState({ highlightRowIndex: index, isPISidebarOpen: true }); - } else { - usePanelsStore.setState({ highlightRowIndex: index, isDataTableOpen: true }); - } + openDataTableAtRowAction(index, isDesktop); }, - [isDesktop] + [openDataTableAtRowAction, isDesktop] ); const handleResetConfirm = useCallback(() => { clearData(); - store.confirmReset(); - }, [clearData, store]); + confirmReset(); + }, [clearData, confirmReset]); // Map store fields to legacy interface return { // Workspace navigation - activeView: store.activeView, - showFrame: store.showFrame, - showAnalysis: store.showAnalysis, - showInvestigation: store.showInvestigation, - showImprovement: store.showImprovement, - showReport: store.showReport, + activeView, + showFrame, + showAnalysis, + showInvestigation, + showImprovement, + showReport, // State (from store) - isSettingsOpen: store.isSettingsOpen, - isDataTableOpen: store.isDataTableOpen, - isFindingsPanelOpen: store.isFindingsOpen, - highlightRowIndex: store.highlightRowIndex, - showExcludedOnly: store.showExcludedOnly, - showResetConfirm: store.showResetConfirm, - isWhatIfPageOpen: store.isWhatIfOpen, - highlightedChartPoint: store.highlightedChartPoint, + isSettingsOpen, + isDataTableOpen, + isFindingsPanelOpen: isFindingsOpen, + highlightRowIndex, + showExcludedOnly, + showResetConfirm, + isWhatIfPageOpen: isWhatIfOpen, + highlightedChartPoint, isDesktop, - openSpecEditorRequested: store.openSpecEditorRequested, - sustainmentTargetId: store.sustainmentTargetId, - handoffTargetId: store.handoffTargetId, - isPISidebarOpen: store.isPISidebarOpen, + openSpecEditorRequested, + sustainmentTargetId, + handoffTargetId, + isPISidebarOpen, // Setters (delegate to store) - setIsSettingsOpen: store.setSettingsOpen, - setIsDataTableOpen: store.setDataTableOpen, - setIsFindingsPanelOpen: store.setFindingsOpen, - setHighlightRowIndex: store.setHighlightRow, - setShowExcludedOnly: store.setShowExcludedOnly, - setShowResetConfirm: store.setShowResetConfirm, - setIsWhatIfPageOpen: store.setWhatIfOpen, - setHighlightedChartPoint: store.setHighlightPoint, - setOpenSpecEditorRequested: store.setOpenSpecEditorRequested, + setIsSettingsOpen: setSettingsOpen, + setIsDataTableOpen: setDataTableOpen, + setIsFindingsPanelOpen: setFindingsOpen, + setHighlightRowIndex: setHighlightRow, + setShowExcludedOnly, + setShowResetConfirm, + setIsWhatIfPageOpen: setWhatIfOpen, + setHighlightedChartPoint: setHighlightPoint, + setOpenSpecEditorRequested, // Compound actions openDataTableAtRow, - handleToggleFindingsPanel: store.toggleFindings, - handleCloseFindingsPanel: () => store.setFindingsOpen(false), - handleCloseDataTable: store.closeDataTable, - openDataTableExcluded: store.openDataTableExcluded, - openDataTableAll: store.openDataTableAll, - handleResetRequest: () => store.setShowResetConfirm(true), + handleToggleFindingsPanel: toggleFindings, + handleCloseFindingsPanel: () => setFindingsOpen(false), + handleCloseDataTable: closeDataTable, + openDataTableExcluded, + openDataTableAll, + handleResetRequest: () => setShowResetConfirm(true), handleResetConfirm, - handleTogglePISidebar: store.togglePISidebar, + handleTogglePISidebar: togglePISidebar, }; } diff --git a/docs/investigations.md b/docs/investigations.md index 009d08e8a..61413037a 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -32,7 +32,7 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo **Description:** 20 findings total — 5 HIGH that qualify the "shipped" claim, 8 MEDIUM spec-vs-shipped drift, 7 LOW cleanups. Followup workstream plan at [`docs/superpowers/plans/2026-05-13-canvas-viewport-8f-followups.md`](superpowers/plans/2026-05-13-canvas-viewport-8f-followups.md). Decision-log "8f canvas viewport SHIPPED" entry has been amended to reference these gaps. Roadmap continues to mark 8f shipped; the followups are a separate cleanup sequence. -**STATUS 2026-05-14 — RESOLVED:** 19 of 20 findings closed by PR #166 (squash-merged as `cd936915` after `--chrome` walk verification). HIGH #4 resolved via spec AMEND (intentional V2 placeholders); HIGH #1/#2/#3/#5 resolved via implementation; all 8 MEDIUM resolved (including the spec §10 amend); 6 of 7 LOW resolved. **Carried forward as separate followups:** LOW #19 (brand `ProcessHubId` — 18-file refactor, low value/risk ratio) + LOW #16 (`Canvas/index.tsx` 1122-line refactor, defer to next viewport feature). Entry retained as historical record; the diff is in `cd936915`. +**STATUS 2026-05-14 — RESOLVED:** 19 of 20 findings closed by PR #166 (squash-merged as `cd936915` after `--chrome` walk verification). HIGH #4 resolved via spec AMEND (intentional V2 placeholders); HIGH #1/#2/#3/#5 resolved via implementation; all 8 MEDIUM resolved (including the spec §10 amend); 6 of 7 LOW resolved. LOW #19 (brand `ProcessHubId`) closed by PR #168 (cleanup/setstate-appmain) — opaque type defined in `packages/core/src/processHub.ts`, sentinel `'__wall-canvas-unbound__'` replaced with `null` short-circuit, 25-file sweep across packages + apps + tests. **Remaining:** LOW #16 (`Canvas/index.tsx` 1122-line refactor, defer to next viewport feature). Entry retained as historical record; the diff is in `cd936915` + PR #168. **HIGH (5):** @@ -60,7 +60,7 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo - `CanvasViewport.tsx` primitive appears unused — `Canvas/index.tsx` inlines the CSS transform via `lodInputSurfaceRef`. Verify and either adopt or delete. - `worldToWallSvg(p, _viewport)` in `coordSpace.ts:22` is identity — delete or document. - Stale `wallLayoutStore` references in `viewStore.ts:140` + `preferencesStore.ts:178` doc strings. -- Sentinel hubId `'__wall-canvas-unbound__'` in `WallCanvas.tsx:248` — brand `ProcessHubId` to prevent leak into the store's `viewports` Record. +- ~~Sentinel hubId `'__wall-canvas-unbound__'` in `WallCanvas.tsx:248` — brand `ProcessHubId` to prevent leak into the store's `viewports` Record.~~ **CLOSED** — PR #168: `ProcessHubId` opaque type in core, `hubId ?? null` sentinel removed, full 25-file sweep. - Missing test — `CanvasLensPicker.tsx` (the lens × level enabled predicate is load-bearing). **Possible directions:** Execute the 6-PR followup plan via `superpowers:subagent-driven-development`. PR0 (docs sync) direct to main; PR1 (i18n + Dexie cleanup + branded hubId), PR2 (ADR-074 cleanup), PR3 (lens matrix — brainstorm first to decide expand-vs-amend), PR4 (LOD polish + dead-code), PR5 (Azure Blob sync — the ADR-081 §2 commitment), PR6 (L3 CTAs + mobile step-list + selector scope + STORE_LAYER rename). @@ -69,7 +69,7 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo --- -### React `setState-in-render` warning fires from `AppMain` across canvas transitions +### [RESOLVED 2026-05-14] React `setState-in-render` warning fires from `AppMain` across canvas transitions **Surfaced by:** `--chrome` walk of PR #166 (canvas-viewport-8f-followups) on 2026-05-14. @@ -81,6 +81,8 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo **Possible directions:** Open in React DevTools profiler during a canvas transition; identify which component logs the violation; replace render-time setState with a `useEffect`, or memoize the selector return reference. +**Resolution (2026-05-14, PR cleanup/setstate-appmain, commit `6c5bc1a7`):** Static analysis of all five named suspects (`useFilteredData`, `useStatsWorker`, `useAnalysisStats`, `useDefectTransform`, `useDefectSummary`) confirmed they are clean (pure `useMemo` or `useEffect`-gated). The actual violation was a bare `const store = usePanelsStore()` whole-store subscription in `apps/pwa/src/hooks/useAppPanels.ts` — directly violating the `packages/stores/CLAUDE.md:18` rule "Never bare `useStore()`" (cites ADR-041). In React 19 Strict Mode + Zustand 5 (`useSyncExternalStore`), a whole-store subscription causes tearing detection to re-invoke the snapshot function, which in turn re-processes the store reference, triggering the warning on every `panelsStore` update (including panel-state transitions co-incident with LOD switches and frame-tab activation). Fix: rewrote `useAppPanels.ts` to use 24 individual `usePanelsStore(s => s.field)` selectors — one per state field and action. `useEffect` dependency arrays cleaned accordingly. Regression test added in `apps/pwa/src/__tests__/App.test.tsx`. + --- ### Canvas journey clarity — designer-lens UX observations from PR #166 walk @@ -113,6 +115,52 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo --- +### Pre-existing tsc errors deferred from PR #168 (cleanup/setstate-appmain) + +**Surfaced by:** PR3 implementer tsc run + controller verification on main, 2026-05-14. + +**Description:** 3 categories of pre-existing tsc errors were not fixed in PR #168 because they require +either new dev-dependency installs or non-trivial test restructuring. + +**Deferred items:** + +1. **d3 module type resolution** — `packages/hooks/src/useCanvasViewportInput.ts:2-4` (`Cannot find +module 'd3-selection' / 'd3-transition' / 'd3-zoom'`) + cascading line 72, 73, 86 errors. + Requires adding `@types/d3-selection`, `@types/d3-zoom`, `@types/d3-transition` to + `packages/hooks/package.json`. (Note: `@types/d3-zoom` and `@types/d3-selection` are already + in `devDependencies`; the issue may be a missing hoisting entry for `@types/d3-transition`.) + Pickup: add/verify the three `@types/d3-*` entries in a follow-up dep-bump PR. + +2. **Tuple-mock typing** — `packages/hooks/src/__tests__/useHubCommentStream.test.ts:274-277`. + `vi.fn(() => Promise.resolve(...))` infers call signature as 0-arg, so `fetchMock.mock.calls[0]` + is typed as an empty tuple `[]`. Fix requires restructuring the fetch mock to carry explicit args + in the factory (`vi.fn, ReturnType>(...)`) or using + `as unknown as MockedFunction`. Out of scope for a trivial-cast PR. + Pickup: next test-quality pass on `useHubCommentStream.test.ts`. + +3. **`beforeEach` globals in `core/src/ai/__tests__/responsesApi.test.ts:862`** — ~~vitest globals + not declared in core tsconfig~~ **CLOSED in PR #168 commit `e73fca64`** by adding `beforeEach` to + the explicit `import { ... } from 'vitest'` on line 1 (more targeted than the `///` reference + directive used in `setup.ts`). + +4. **Entity fixture-shape mismatches in core tests (newly surfaced post-fix)** — once `responsesApi.test.ts` + was unblocked, core tsc reveals 9 more pre-existing errors: + - `packages/core/src/__tests__/processHub.test.ts:722, 732, 1164` + `processState.test.ts:180` + + `sustainment.test.ts:546` — `SustainmentRecord` fixtures are missing required fields added since + they were written: `status`, `title`, `consecutiveOnTargetTicks`, `hasOverride`, `lastEvaluatedSnapshotId` + (the entity grew during RPS V1 work without test-fixture catch-up). + - `packages/core/src/canvas/__tests__/stampStepCapabilities.test.ts:9, 64, 70, 91` — `ProcessMap` + fixtures missing `version`, `tributaries`, `createdAt`, `updatedAt`; plus two `null` vs `string | undefined` + assignment errors. + Pickup: a focused "fixture catch-up" PR that adds the missing required fields to each fixture (preferred + over blanket `as` casts — the casts mask real schema-vs-fixture drift). Touches 4 files in `packages/core/src/__tests__/` + and `packages/core/src/canvas/__tests__/`. + +**Not a blocking concern** — tsc runs per-package in isolation; vitest runs under bundler transforms +that supply vite globals. Runtime behaviour is unaffected. + +--- + ### Stats-bar "Set specs →" link reads project-wide specs only **Surfaced by:** FRAME b0 spec wiring fixes, 2026-05-03 (branch `feature/full-vision-frame-b0`). diff --git a/packages/core/src/__tests__/processHub.test.ts b/packages/core/src/__tests__/processHub.test.ts index 70ed2ee83..3e707c021 100644 --- a/packages/core/src/__tests__/processHub.test.ts +++ b/packages/core/src/__tests__/processHub.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_PROCESS_HUB, DEFAULT_PROCESS_HUB_ID, + asProcessHubId, buildProcessHubCadence, buildProcessHubContext, buildProcessHubReview, buildProcessHubRollups, + isProcessHubId, normalizeProcessHubId, } from '../processHub'; import type { ProcessHub, ProjectMetadata } from '../index'; @@ -1096,6 +1098,52 @@ describe('buildProcessHubRollups', () => { }); }); +describe('asProcessHubId', () => { + it('returns a ProcessHubId whose string value equals the input', () => { + const id = asProcessHubId('valid-hub'); + expect(id).toBe('valid-hub'); + }); + + it('trims surrounding whitespace and returns the trimmed value', () => { + const id = asProcessHubId(' trimmed-id '); + expect(id).toBe('trimmed-id'); + }); + + it('throws on empty string with a message referencing asProcessHubId', () => { + expect(() => asProcessHubId('')).toThrow(/asProcessHubId/); + }); + + it('throws on whitespace-only string (blank is treated as invalid)', () => { + expect(() => asProcessHubId(' ')).toThrow(); + }); +}); + +describe('isProcessHubId', () => { + it('returns true for a non-empty string', () => { + expect(isProcessHubId('hub-1')).toBe(true); + }); + + it('returns false for an empty string', () => { + expect(isProcessHubId('')).toBe(false); + }); + + it('returns false for null', () => { + expect(isProcessHubId(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isProcessHubId(undefined)).toBe(false); + }); + + it('returns false for a number', () => { + expect(isProcessHubId(42)).toBe(false); + }); + + it('returns false for a plain object', () => { + expect(isProcessHubId({})).toBe(false); + }); +}); + describe('buildProcessHubContext — sustainment', () => { it('exposes due, overdue, and verdict counts (no PII)', () => { const now = new Date('2026-04-26T00:00:00.000Z'); diff --git a/packages/core/src/ai/__tests__/responsesApi.test.ts b/packages/core/src/ai/__tests__/responsesApi.test.ts index 6dac8dd64..f34b40e22 100644 --- a/packages/core/src/ai/__tests__/responsesApi.test.ts +++ b/packages/core/src/ai/__tests__/responsesApi.test.ts @@ -1,4 +1,4 @@ -import { vi, describe, it, expect, afterEach } from 'vitest'; +import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest'; import { ResponsesApiError, retryWithBackoff, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7642133de..0b973fa7e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -515,6 +515,8 @@ export { DEFAULT_PROCESS_HUB, DEFAULT_PROCESS_HUB_ID, DEFAULT_PROCESS_HUB_NAME, + asProcessHubId, + isProcessHubId, buildProcessHubCadence, buildProcessHubContext, buildProcessHubReview, @@ -523,6 +525,7 @@ export { isProcessHubComplete, normalizeProcessHubId, } from './processHub'; +export type { ProcessHubId } from './processHub'; export { buildCurrentProcessState } from './processState'; export type { CurrentProcessState, diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index bc7dc2624..f371caae0 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -32,7 +32,35 @@ export { buildReviewItem } from './processHubReview'; export { isCharterReady, isSustainmentReady, isHandoffReady } from './responsePathReadiness'; export type { WorkflowReadinessSignals } from './responsePathReadiness'; -export const DEFAULT_PROCESS_HUB_ID = 'general-unassigned'; +/** + * Opaque brand type for ProcessHub identifiers. + * Use `asProcessHubId()` to construct from a plain string, or + * `normalizeProcessHubId()` which returns a validated `ProcessHubId`. + */ +export type ProcessHubId = string & { readonly __brand: 'ProcessHubId' }; + +/** + * Construct a `ProcessHubId` from a plain string. + * Throws on empty/blank input (loud failure per feedback_strict_assert_over_silent_migration) + * rather than silently falling back — callers that want the fallback behaviour + * should use `normalizeProcessHubId()` instead. + */ +export function asProcessHubId(value: string): ProcessHubId { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error( + `asProcessHubId: value must be a non-empty string, got: ${JSON.stringify(value)}` + ); + } + return trimmed as ProcessHubId; +} + +/** Type-guard: true if `value` is a non-empty string (runtime brand check). */ +export function isProcessHubId(value: unknown): value is ProcessHubId { + return typeof value === 'string' && value.trim().length > 0; +} + +export const DEFAULT_PROCESS_HUB_ID: ProcessHubId = 'general-unassigned' as ProcessHubId; export const DEFAULT_PROCESS_HUB_NAME = 'General / Unassigned'; export type InvestigationDepth = 'quick' | 'focused' | 'chartered'; @@ -477,9 +505,9 @@ const CADENCE_QUEUE_LIMIT = 4; const SUSTAINMENT_STATUSES = new Set(['resolved', 'controlled']); -export function normalizeProcessHubId(processHubId?: string | null): string { +export function normalizeProcessHubId(processHubId?: string | null): ProcessHubId { const trimmed = processHubId?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_PROCESS_HUB_ID; + return trimmed && trimmed.length > 0 ? asProcessHubId(trimmed) : DEFAULT_PROCESS_HUB_ID; } /** diff --git a/packages/core/src/vite-env.d.ts b/packages/core/src/vite-env.d.ts new file mode 100644 index 000000000..a8c81f6b1 --- /dev/null +++ b/packages/core/src/vite-env.d.ts @@ -0,0 +1,30 @@ +/** + * Minimal ImportMeta augmentation for packages that use import.meta.env / import.meta.glob + * but do not have `vite` as a direct dependency (and therefore cannot use + * `/// `). + * + * Covers the two call-sites in this package: + * - import.meta.env.DEV (legacy.ts, narration.ts) + * - import.meta.glob(...) (i18n/__tests__/index.test.ts) + */ + +interface ImportMetaEnv { + readonly DEV: boolean; + readonly PROD: boolean; + readonly MODE: string; + [key: string]: string | boolean | undefined; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; + // Eager mode — returns the module directly + glob( + pattern: string | string[], + options: { eager: true; as?: string } + ): Record; + // Lazy mode (default) — returns a dynamic import factory + glob( + pattern: string | string[], + options?: { eager?: false; as?: string } + ): Record Promise>; +} diff --git a/packages/hooks/src/__tests__/findingSourceLensCapture.test.ts b/packages/hooks/src/__tests__/findingSourceLensCapture.test.ts index 46c2713f8..d53009765 100644 --- a/packages/hooks/src/__tests__/findingSourceLensCapture.test.ts +++ b/packages/hooks/src/__tests__/findingSourceLensCapture.test.ts @@ -119,7 +119,7 @@ describe('finding replay — setTimeLens restored from source.timeLens', () => { callOrder.push('setTimeLens'); usePreferencesStore.getState().setTimeLens(lens); }); - const mockSetFilters = vi.fn(() => { + const mockSetFilters = vi.fn((_filters: unknown) => { callOrder.push('setFilters'); }); diff --git a/packages/hooks/src/__tests__/setup.ts b/packages/hooks/src/__tests__/setup.ts index f5e705fdb..ef0215c84 100644 --- a/packages/hooks/src/__tests__/setup.ts +++ b/packages/hooks/src/__tests__/setup.ts @@ -1,3 +1,4 @@ +/// /** * Hooks test setup — supplements the root test/setup.ts. * @@ -103,7 +104,7 @@ if (typeof window !== 'undefined') { const db = makeDb(); - (window as Record)['indexedDB'] = { + (window as unknown as Record)['indexedDB'] = { open: (_dbName: string, _version?: number) => { const openReq = { result: db, diff --git a/packages/hooks/src/__tests__/timeLensWiring.test.ts b/packages/hooks/src/__tests__/timeLensWiring.test.ts index 52e8b453c..69a0ceec8 100644 --- a/packages/hooks/src/__tests__/timeLensWiring.test.ts +++ b/packages/hooks/src/__tests__/timeLensWiring.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { usePreferencesStore, getPreferencesInitialState } from '@variscout/stores'; import { useProjectStore, getProjectInitialState } from '@variscout/stores'; +import type { DataRow } from '@variscout/core'; import { useIChartData } from '../useIChartData'; import { useBoxplotData } from '../useBoxplotData'; import { useProbabilityPlotData } from '../useProbabilityPlotData'; @@ -45,7 +46,7 @@ function buildParetoRows(n: number): Record[] { count: 1, })); } -const PARETO_100 = buildParetoRows(100); +const PARETO_100 = buildParetoRows(100) as DataRow[]; const NO_FILTERS: Record = {}; // --------------------------------------------------------------------------- diff --git a/packages/hooks/src/__tests__/useCanvasHypothesisArrows.test.ts b/packages/hooks/src/__tests__/useCanvasHypothesisArrows.test.ts new file mode 100644 index 000000000..39953dad5 --- /dev/null +++ b/packages/hooks/src/__tests__/useCanvasHypothesisArrows.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useCanvasHypothesisArrows } from '../useCanvasHypothesisArrows'; +import type { RefObject } from 'react'; +import type { CanvasInvestigationOverlayModel } from '../useCanvasInvestigationOverlays'; + +type ResizeObserverCallback = (entries: ResizeObserverEntry[]) => void; + +function makeSurfaceRef(rect?: Partial): RefObject { + const el = document.createElement('div'); + const fullRect = { + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 400, + width: 800, + height: 400, + toJSON: () => ({}), + ...rect, + } as DOMRect; + el.getBoundingClientRect = () => fullRect; + return { current: el }; +} + +function makeOverlays( + fromStepId: string, + toStepId: string, + id = 'link-1' +): CanvasInvestigationOverlayModel { + return { + byStep: {}, + arrows: [ + { + id, + fromStepId, + toStepId, + label: 'link', + questionId: 'q-1', + focus: { kind: 'causal-link', id, questionId: 'q-1' }, + }, + ], + unresolved: { questions: [], findings: [], hypotheses: [], causalLinks: [] }, + }; +} + +function renderArrowsHook( + overrides: Partial[0]> = {} +) { + const cardSurfaceRef = makeSurfaceRef(); + + const { result, rerender } = renderHook( + (props: Partial[0]>) => + useCanvasHypothesisArrows({ + resolvedOverlays: ['hypotheses'], + investigationOverlays: undefined, + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: 0, + viewportPanY: 0, + viewportZoom: 1, + ...overrides, + ...props, + }), + { initialProps: {} } + ); + + return { result, rerender, cardSurfaceRef }; +} + +describe('useCanvasHypothesisArrows', () => { + beforeEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + vi.restoreAllMocks(); + }); + + it('starts with empty arrow segments', () => { + const { result } = renderArrowsHook(); + + expect(result.current.arrowSegments).toEqual([]); + }); + + it('returns a stable registerCardElement callback', () => { + const { result } = renderArrowsHook(); + const first = result.current.registerCardElement; + expect(result.current.registerCardElement).toBe(first); + }); + + it('registerCardElement stores elements and removes on null', () => { + // Since cardElements is internal, we verify indirectly: + // register an element so it can participate in arrow measurement, + // then remove it and ensure no crash. + const cardSurfaceRef = makeSurfaceRef(); + const { result } = renderHook(() => + useCanvasHypothesisArrows({ + resolvedOverlays: ['hypotheses'], + investigationOverlays: makeOverlays('step-1', 'step-2'), + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: 0, + viewportPanY: 0, + viewportZoom: 1, + }) + ); + + const el = document.createElement('div'); + document.body.appendChild(el); + + act(() => { + result.current.registerCardElement('step-1', el); + }); + + act(() => { + result.current.registerCardElement('step-1', null); + }); + + // No crash — passes if we get here + expect(result.current.arrowSegments).toBeDefined(); + }); + + it('returns empty segments when hypotheses overlay is not active', () => { + const cardSurfaceRef = makeSurfaceRef(); + const { result } = renderHook(() => + useCanvasHypothesisArrows({ + resolvedOverlays: ['findings'], + investigationOverlays: makeOverlays('step-1', 'step-2'), + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: 0, + viewportPanY: 0, + viewportZoom: 1, + }) + ); + + expect(result.current.arrowSegments).toEqual([]); + }); + + it('returns empty segments when investigationOverlays is undefined', () => { + const { result } = renderArrowsHook({ investigationOverlays: undefined }); + + expect(result.current.arrowSegments).toEqual([]); + }); + + it('computes arrow segments from registered card elements when viewport changes', () => { + // The arrow measurement layout effect reads cardElements.current (a ref) and fires + // when viewportPanX/Y/Zoom deps change. Register elements first, then trigger a + // viewport change to force the measurement effect to run with the registered elements. + const cardSurfaceRef = makeSurfaceRef({ left: 0, top: 0 } as Partial); + + const fromEl = document.createElement('div'); + const toEl = document.createElement('div'); + + fromEl.getBoundingClientRect = () => + ({ left: 50, top: 50, width: 100, height: 80, toJSON: () => ({}) }) as DOMRect; + toEl.getBoundingClientRect = () => + ({ left: 300, top: 50, width: 100, height: 80, toJSON: () => ({}) }) as DOMRect; + + document.body.appendChild(fromEl); + document.body.appendChild(toEl); + + const { result, rerender } = renderHook( + ({ zoom }: { zoom: number }) => + useCanvasHypothesisArrows({ + resolvedOverlays: ['hypotheses'], + investigationOverlays: makeOverlays('step-1', 'step-2'), + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: 0, + viewportPanY: 0, + viewportZoom: zoom, + }), + { initialProps: { zoom: 1 } } + ); + + // Register elements directly (mutates ref, doesn't trigger re-render) + result.current.registerCardElement('step-1', fromEl); + result.current.registerCardElement('step-2', toEl); + + // Trigger the measurement effect by changing a dep + rerender({ zoom: 1.0001 }); + + expect(result.current.arrowSegments).toHaveLength(1); + expect(result.current.arrowSegments[0]).toMatchObject({ + id: 'link-1', + x1: 100, // 50 + 100/2 - 0 (surface left) + y1: 90, // 50 + 80/2 - 0 (surface top) + x2: 350, // 300 + 100/2 - 0 + y2: 90, + }); + }); + + it('attaches and detaches ResizeObserver when conditions are met', () => { + const resizeCallbacks: ResizeObserverCallback[] = []; + const mockObserve = vi.fn(); + const mockDisconnect = vi.fn(); + + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(callback); + } + observe = mockObserve; + disconnect = mockDisconnect; + unobserve = vi.fn(); + } + ); + + const cardSurfaceRef = makeSurfaceRef(); + + const { unmount } = renderHook(() => + useCanvasHypothesisArrows({ + resolvedOverlays: ['hypotheses'], + investigationOverlays: makeOverlays('step-1', 'step-2'), + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: 0, + viewportPanY: 0, + viewportZoom: 1, + }) + ); + + expect(mockObserve).toHaveBeenCalledWith(cardSurfaceRef.current); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('remeasures segments when viewport pan changes', () => { + const cardSurfaceRef = makeSurfaceRef({ left: 0, top: 0 } as Partial); + + const fromEl = document.createElement('div'); + const toEl = document.createElement('div'); + let panOffset = 0; + + fromEl.getBoundingClientRect = () => + ({ left: 50 + panOffset, top: 50, width: 100, height: 80, toJSON: () => ({}) }) as DOMRect; + toEl.getBoundingClientRect = () => + ({ left: 300 + panOffset, top: 50, width: 100, height: 80, toJSON: () => ({}) }) as DOMRect; + + document.body.appendChild(fromEl); + document.body.appendChild(toEl); + + const { result, rerender } = renderHook( + ({ panX }: { panX: number }) => + useCanvasHypothesisArrows({ + resolvedOverlays: ['hypotheses'], + investigationOverlays: makeOverlays('step-1', 'step-2'), + cardSurfaceRef, + resolvedLens: 'default', + stepCards: [], + viewportPanX: panX, + viewportPanY: 0, + viewportZoom: 1, + }), + { initialProps: { panX: 0 } } + ); + + // Register elements (mutates ref) then trigger measurement via zoom change + result.current.registerCardElement('step-1', fromEl); + result.current.registerCardElement('step-2', toEl); + rerender({ panX: 0.0001 }); // bump to trigger measurement with current elements + + const initialX1 = result.current.arrowSegments[0]?.x1; + expect(initialX1).toBeDefined(); + + // Now change pan offset and trigger remeasurement + panOffset = 20; + rerender({ panX: 20 }); + + // After rerender with new panX, the effect runs and measures new positions + expect(result.current.arrowSegments[0]?.x1).toBe(initialX1! + 20); + }); +}); diff --git a/packages/hooks/src/__tests__/useCanvasHypothesisDrawing.test.ts b/packages/hooks/src/__tests__/useCanvasHypothesisDrawing.test.ts new file mode 100644 index 000000000..466f0f04a --- /dev/null +++ b/packages/hooks/src/__tests__/useCanvasHypothesisDrawing.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useCanvasHypothesisDrawing } from '../useCanvasHypothesisDrawing'; +import { useHypothesisDrawTool } from '../useHypothesisDrawTool'; +import type { KeyboardEvent, PointerEvent, RefObject } from 'react'; +import type { ArrowEndpoint } from '../useHypothesisDrawTool'; + +const stepA: ArrowEndpoint = { kind: 'step', id: 'step-a' }; +const stepB: ArrowEndpoint = { kind: 'step', id: 'step-b' }; + +function makeElement(attrs: Record = {}): HTMLElement { + const el = document.createElement('div'); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + return el; +} + +function makeSurfaceRef(rect?: Partial): RefObject { + const el = document.createElement('div'); + const fullRect = { + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: 400, + width: 800, + height: 400, + toJSON: () => ({}), + ...rect, + } as DOMRect; + el.getBoundingClientRect = () => fullRect; + return { current: el }; +} + +function renderDrawingHook( + overrides: Partial[0]> = {} +) { + const cardSurfaceRef = makeSurfaceRef(); + + const { result: drawToolResult } = renderHook(() => + useHypothesisDrawTool({ active: overrides.activeCanvasTool === 'draw-hypothesis' }) + ); + + const { result } = renderHook(() => + useCanvasHypothesisDrawing({ + activeCanvasTool: 'draw-hypothesis', + disabled: false, + drawTool: drawToolResult.current, + cardSurfaceRef, + onCanvasToolChange: vi.fn(), + stepMetricColumns: { 'step-a': 'Pressure', 'step-b': 'Defect' }, + ...overrides, + }) + ); + + return { result, drawToolResult, cardSurfaceRef }; +} + +describe('useCanvasHypothesisDrawing', () => { + beforeEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + it('returns handlers and endpointLabel', () => { + const { result } = renderDrawingHook(); + + expect(result.current.handlers.onPointerDown).toBeInstanceOf(Function); + expect(result.current.handlers.onPointerMove).toBeInstanceOf(Function); + expect(result.current.handlers.onPointerUp).toBeInstanceOf(Function); + expect(result.current.handlers.onKeyDown).toBeInstanceOf(Function); + expect(result.current.endpointLabel).toBeInstanceOf(Function); + }); + + it('endpointLabel returns column name for column endpoints', () => { + const { result } = renderDrawingHook({ + stepMetricColumns: { 'step-a': 'Pressure' }, + }); + const colEndpoint: ArrowEndpoint = { + kind: 'column', + name: 'Temperature', + hostStepId: 'step-a', + }; + + expect(result.current.endpointLabel(colEndpoint)).toBe('Temperature'); + }); + + it('endpointLabel returns metric column for step endpoints', () => { + const { result } = renderDrawingHook({ + stepMetricColumns: { 'step-a': 'Pressure', 'step-b': 'Defect' }, + }); + + expect(result.current.endpointLabel(stepA)).toBe('Pressure'); + expect(result.current.endpointLabel(stepB)).toBe('Defect'); + }); + + it('endpointLabel falls back to step id when no metric column', () => { + const { result } = renderDrawingHook({ + stepMetricColumns: {}, + }); + + expect(result.current.endpointLabel(stepA)).toBe('step-a'); + }); + + it('parseEndpointElement returns null for null input', () => { + const { result } = renderDrawingHook(); + + expect(result.current.parseEndpointElement(null)).toBeNull(); + }); + + it('parseEndpointElement resolves step endpoints', () => { + const { result } = renderDrawingHook(); + const el = makeElement({ 'data-arrow-endpoint': 'step:step-a' }); + document.body.appendChild(el); + + expect(result.current.parseEndpointElement(el)).toEqual({ kind: 'step', id: 'step-a' }); + }); + + it('parseEndpointElement resolves column endpoints with host step in same element', () => { + const { result } = renderDrawingHook(); + const el = makeElement({ + 'data-arrow-endpoint': 'column:Pressure', + 'data-arrow-host-step-id': 'step-a', + }); + document.body.appendChild(el); + + expect(result.current.parseEndpointElement(el)).toEqual({ + kind: 'column', + name: 'Pressure', + hostStepId: 'step-a', + }); + }); + + it('parseEndpointElement resolves column endpoints when host step is in parent', () => { + const { result } = renderDrawingHook(); + const parent = makeElement({ 'data-arrow-host-step-id': 'step-b' }); + const child = makeElement({ 'data-arrow-endpoint': 'column:Defect' }); + parent.appendChild(child); + document.body.appendChild(parent); + + expect(result.current.parseEndpointElement(child)).toEqual({ + kind: 'column', + name: 'Defect', + hostStepId: 'step-b', + }); + }); + + it('handleKeyDown does nothing when tool is not draw-hypothesis', () => { + const onCanvasToolChange = vi.fn(); + const { result } = renderDrawingHook({ + activeCanvasTool: 'select', + onCanvasToolChange, + }); + + act(() => { + result.current.handlers.onKeyDown({ + key: 'Escape', + preventDefault: vi.fn(), + target: document.createElement('div'), + } as unknown as KeyboardEvent); + }); + + expect(onCanvasToolChange).not.toHaveBeenCalled(); + }); + + it('handleKeyDown cancels and switches tool on Escape', () => { + const onCanvasToolChange = vi.fn(); + const { result: drawToolResult } = renderHook(() => useHypothesisDrawTool({ active: true })); + const cardSurfaceRef = makeSurfaceRef(); + const { result } = renderHook(() => + useCanvasHypothesisDrawing({ + activeCanvasTool: 'draw-hypothesis', + disabled: false, + drawTool: drawToolResult.current, + cardSurfaceRef, + onCanvasToolChange, + stepMetricColumns: {}, + }) + ); + + // Start drawing first + act(() => { + drawToolResult.current.onPointerDown(stepA, { x: 10, y: 20 }); + }); + + act(() => { + result.current.handlers.onKeyDown({ + key: 'Escape', + preventDefault: vi.fn(), + target: document.createElement('div'), + } as unknown as KeyboardEvent); + }); + + expect(onCanvasToolChange).toHaveBeenCalledWith('select'); + }); + + it('starts drawing on pointer down over a step endpoint', () => { + const { result: drawToolResult } = renderHook(() => useHypothesisDrawTool({ active: true })); + const cardSurfaceRef = makeSurfaceRef(); + const { result } = renderHook(() => + useCanvasHypothesisDrawing({ + activeCanvasTool: 'draw-hypothesis', + disabled: false, + drawTool: drawToolResult.current, + cardSurfaceRef, + stepMetricColumns: { 'step-a': 'Pressure', 'step-b': 'Defect' }, + }) + ); + + const sourceEl = makeElement({ 'data-arrow-endpoint': 'step:step-a' }); + const mockRect = { + left: 50, + top: 50, + width: 100, + height: 80, + right: 150, + bottom: 130, + x: 50, + y: 50, + toJSON: () => ({}), + } as DOMRect; + sourceEl.getBoundingClientRect = () => mockRect; + document.body.appendChild(sourceEl); + + act(() => { + result.current.handlers.onPointerDown({ + target: sourceEl, + clientX: 80, + clientY: 80, + preventDefault: vi.fn(), + } as unknown as PointerEvent); + }); + + expect(drawToolResult.current.state.phase).toBe('drawing'); + }); + + it('ignores pointer down when disabled', () => { + const { result: drawToolResult } = renderHook(() => useHypothesisDrawTool({ active: true })); + const cardSurfaceRef = makeSurfaceRef(); + const { result } = renderHook(() => + useCanvasHypothesisDrawing({ + activeCanvasTool: 'draw-hypothesis', + disabled: true, + drawTool: drawToolResult.current, + cardSurfaceRef, + stepMetricColumns: {}, + }) + ); + + const sourceEl = makeElement({ 'data-arrow-endpoint': 'step:step-a' }); + document.body.appendChild(sourceEl); + + act(() => { + result.current.handlers.onPointerDown({ + target: sourceEl, + clientX: 80, + clientY: 80, + preventDefault: vi.fn(), + } as unknown as PointerEvent); + }); + + expect(drawToolResult.current.state.phase).toBe('idle'); + }); +}); diff --git a/packages/hooks/src/__tests__/useCanvasViewportInput.test.ts b/packages/hooks/src/__tests__/useCanvasViewportInput.test.ts index 7fdc202b0..5134e578b 100644 --- a/packages/hooks/src/__tests__/useCanvasViewportInput.test.ts +++ b/packages/hooks/src/__tests__/useCanvasViewportInput.test.ts @@ -1,11 +1,8 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, beforeEach } from 'vitest'; import { useRef, type RefObject } from 'react'; -import { - getCanvasViewportInitialState, - useCanvasViewportStore, - type ProcessHubId, -} from '@variscout/stores'; +import { getCanvasViewportInitialState, useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useCanvasViewportInput, snapTarget } from '../useCanvasViewportInput'; interface D3ZoomElement extends HTMLDivElement { @@ -13,7 +10,7 @@ interface D3ZoomElement extends HTMLDivElement { __on?: Array<{ type: string; name: string; value: (event: Event) => void }>; } -const HUB_ID: ProcessHubId = 'hub-canvas-input'; +const HUB_ID = 'hub-canvas-input' as ProcessHubId; function makeCanvasElement(): D3ZoomElement { const element = document.createElement('div') as D3ZoomElement; @@ -205,6 +202,30 @@ describe('useCanvasViewportInput', () => { pan: { x: 0, y: 0 }, }); }); + + it('is a no-op when hubId is null — does not attach d3 listeners or update the store', () => { + const element = makeCanvasElement(); + const ref: RefObject = { current: element }; + renderHook(() => useCanvasViewportInput({ hubId: null, ref })); + + expect(element.__zoom).toBeUndefined(); + expect(element.__on?.some(listener => listener.name === 'zoom')).not.toBe(true); + + element.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: -180, + clientX: 100, + clientY: 50, + }) + ); + + expect(useCanvasViewportStore.getState().getViewport(HUB_ID)).toMatchObject({ + zoom: 1, + pan: { x: 0, y: 0 }, + }); + }); }); describe('snapTarget — LOD boundary snap logic', () => { diff --git a/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts b/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts index a7408bf58..595f9124b 100644 --- a/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts +++ b/packages/hooks/src/__tests__/useCanvasViewportShortcuts.test.ts @@ -1,13 +1,10 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; -import { - getCanvasViewportInitialState, - useCanvasViewportStore, - type ProcessHubId, -} from '@variscout/stores'; +import { getCanvasViewportInitialState, useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useCanvasViewportShortcuts } from '../useCanvasViewportShortcuts'; -const HUB_ID: ProcessHubId = 'hub-canvas-shortcuts'; +const HUB_ID = 'hub-canvas-shortcuts' as ProcessHubId; type ShortcutKeyInit = { altKey?: boolean; diff --git a/packages/hooks/src/__tests__/useSharedWallProps.test.ts b/packages/hooks/src/__tests__/useSharedWallProps.test.ts index 77ddd2249..fd047916c 100644 --- a/packages/hooks/src/__tests__/useSharedWallProps.test.ts +++ b/packages/hooks/src/__tests__/useSharedWallProps.test.ts @@ -6,6 +6,7 @@ import { useCanvasViewportStore, useInvestigationStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useSharedWallProps, type UseSharedWallPropsArgs, @@ -81,8 +82,8 @@ const processMap: ProcessMap = { createdAt: '2026-05-08T00:00:00.000Z', updatedAt: '2026-05-08T00:00:00.000Z', }; -const HUB_ID = 'hub-shared-wall-props'; -const OTHER_HUB_ID = 'hub-other-wall-props'; +const HUB_ID = 'hub-shared-wall-props' as ProcessHubId; +const OTHER_HUB_ID = 'hub-other-wall-props' as ProcessHubId; beforeEach(() => { useInvestigationStore.setState(getInvestigationInitialState()); diff --git a/packages/hooks/src/__tests__/useYamazumiChartData.test.ts b/packages/hooks/src/__tests__/useYamazumiChartData.test.ts index 5948e36c3..d679dd04c 100644 --- a/packages/hooks/src/__tests__/useYamazumiChartData.test.ts +++ b/packages/hooks/src/__tests__/useYamazumiChartData.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; import { useYamazumiChartData } from '../useYamazumiChartData'; -import type { YamazumiColumnMapping } from '@variscout/core'; +import type { YamazumiColumnMapping, DataRow } from '@variscout/core'; const testData = [ { Step: 'Pick', Activity_Type: 'VA', Cycle_Time: 30, Activity: 'Get tool', Reason: '' }, @@ -22,7 +22,7 @@ const mapping: YamazumiColumnMapping = { reasonColumn: 'Reason', }; -const EMPTY_DATA: Record[] = []; +const EMPTY_DATA: DataRow[] = []; describe('useYamazumiChartData', () => { it('returns [] when mapping is null', () => { diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index dcaa3025a..7c78be2e5 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -57,6 +57,19 @@ export { } from './useCanvasViewportInput'; export { useCanvasViewportShortcuts } from './useCanvasViewportShortcuts'; +export { + useCanvasHypothesisDrawing, + type UseCanvasHypothesisDrawingArgs, + type UseCanvasHypothesisDrawingResult, +} from './useCanvasHypothesisDrawing'; + +export { + useCanvasHypothesisArrows, + type ArrowSegment, + type UseCanvasHypothesisArrowsArgs, + type UseCanvasHypothesisArrowsResult, +} from './useCanvasHypothesisArrows'; + export { useHypothesisDrawTool, resolveEndpointToFactor, diff --git a/packages/hooks/src/useCanvasHypothesisArrows.ts b/packages/hooks/src/useCanvasHypothesisArrows.ts new file mode 100644 index 000000000..8caa7242a --- /dev/null +++ b/packages/hooks/src/useCanvasHypothesisArrows.ts @@ -0,0 +1,131 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import type { CanvasLensId, CanvasStepCardModel } from './useCanvasStepCards'; +import type { + CanvasInvestigationOverlayModel, + CanvasOverlayId, +} from './useCanvasInvestigationOverlays'; + +export type ArrowSegment = { + id: string; + x1: number; + y1: number; + x2: number; + y2: number; +}; + +function areArrowSegmentsEqual(left: ArrowSegment[], right: ArrowSegment[]): boolean { + if (left.length !== right.length) return false; + return left.every((segment, index) => { + const next = right[index]; + return ( + segment.id === next.id && + segment.x1 === next.x1 && + segment.y1 === next.y1 && + segment.x2 === next.x2 && + segment.y2 === next.y2 + ); + }); +} + +export interface UseCanvasHypothesisArrowsArgs { + resolvedOverlays: readonly CanvasOverlayId[]; + investigationOverlays: CanvasInvestigationOverlayModel | undefined; + cardSurfaceRef: RefObject; + resolvedLens: CanvasLensId; + stepCards: readonly CanvasStepCardModel[]; + viewportPanX: number; + viewportPanY: number; + viewportZoom: number; +} + +export interface UseCanvasHypothesisArrowsResult { + arrowSegments: ArrowSegment[]; + registerCardElement: (stepId: string, element: HTMLElement | null) => void; +} + +export function useCanvasHypothesisArrows({ + resolvedOverlays, + investigationOverlays, + cardSurfaceRef, + resolvedLens, + stepCards, + viewportPanX, + viewportPanY, + viewportZoom, +}: UseCanvasHypothesisArrowsArgs): UseCanvasHypothesisArrowsResult { + const cardElements = useRef(new Map()); + const [arrowSegments, setArrowSegments] = useState([]); + const [arrowMeasureVersion, setArrowMeasureVersion] = useState(0); + + const registerCardElement = useCallback((stepId: string, element: HTMLElement | null) => { + if (element) cardElements.current.set(stepId, element); + else cardElements.current.delete(stepId); + }, []); + + useLayoutEffect(() => { + if ( + !resolvedOverlays.includes('hypotheses') || + !investigationOverlays || + !cardSurfaceRef.current + ) { + return; + } + + const refresh = () => setArrowMeasureVersion(version => version + 1); + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(refresh); + resizeObserver?.observe(cardSurfaceRef.current); + for (const element of cardElements.current.values()) resizeObserver?.observe(element); + window.addEventListener('resize', refresh); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', refresh); + }; + }, [cardSurfaceRef, investigationOverlays, resolvedLens, resolvedOverlays, stepCards]); + + useLayoutEffect(() => { + if ( + !resolvedOverlays.includes('hypotheses') || + !investigationOverlays || + !cardSurfaceRef.current + ) { + setArrowSegments(current => (current.length === 0 ? current : [])); + return; + } + const surfaceRect = cardSurfaceRef.current.getBoundingClientRect(); + const next = investigationOverlays.arrows.flatMap(arrow => { + const from = cardElements.current.get(arrow.fromStepId); + const to = cardElements.current.get(arrow.toStepId); + if (!from || !to) return []; + const fromRect = from.getBoundingClientRect(); + const toRect = to.getBoundingClientRect(); + return [ + { + id: arrow.id, + x1: fromRect.left + fromRect.width / 2 - surfaceRect.left, + y1: fromRect.top + fromRect.height / 2 - surfaceRect.top, + x2: toRect.left + toRect.width / 2 - surfaceRect.left, + y2: toRect.top + toRect.height / 2 - surfaceRect.top, + }, + ]; + }); + setArrowSegments(current => (areArrowSegmentsEqual(current, next) ? current : next)); + }, [ + arrowMeasureVersion, + cardSurfaceRef, + investigationOverlays, + resolvedLens, + resolvedOverlays, + stepCards, + viewportPanX, + viewportPanY, + viewportZoom, + ]); + + return { + arrowSegments, + registerCardElement, + }; +} diff --git a/packages/hooks/src/useCanvasHypothesisDrawing.ts b/packages/hooks/src/useCanvasHypothesisDrawing.ts new file mode 100644 index 000000000..b95b20a1c --- /dev/null +++ b/packages/hooks/src/useCanvasHypothesisDrawing.ts @@ -0,0 +1,204 @@ +import { useCallback, useEffect } from 'react'; +import type { KeyboardEvent, PointerEvent, RefObject } from 'react'; +import type { ArrowEndpoint, UseHypothesisDrawToolResult } from './useHypothesisDrawTool'; +import type { CanvasToolId } from './useHypothesisDrawTool'; + +export interface UseCanvasHypothesisDrawingArgs { + activeCanvasTool: CanvasToolId; + disabled?: boolean; + drawTool: UseHypothesisDrawToolResult; + cardSurfaceRef: RefObject; + onCanvasToolChange?: (next: CanvasToolId) => void; + stepMetricColumns: Record; +} + +export interface UseCanvasHypothesisDrawingResult { + handlers: { + onPointerDown: (e: PointerEvent) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; + }; + endpointLabel: (endpoint: ArrowEndpoint) => string; + parseEndpointElement: (element: Element | null) => ArrowEndpoint | null; +} + +export function useCanvasHypothesisDrawing({ + activeCanvasTool, + disabled, + drawTool, + cardSurfaceRef, + onCanvasToolChange, + stepMetricColumns, +}: UseCanvasHypothesisDrawingArgs): UseCanvasHypothesisDrawingResult { + const surfacePoint = useCallback( + (clientX: number, clientY: number): { x: number; y: number } => { + const rect = cardSurfaceRef.current?.getBoundingClientRect(); + return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }; + }, + [cardSurfaceRef] + ); + + const endpointElementFromTarget = useCallback((target: EventTarget | null): Element | null => { + return target instanceof Element ? target.closest('[data-arrow-endpoint]') : null; + }, []); + + const parseEndpointElement = useCallback((element: Element | null): ArrowEndpoint | null => { + let node: Element | null = element; + while (node) { + const attr = node.getAttribute('data-arrow-endpoint'); + if (attr) { + const separator = attr.indexOf(':'); + if (separator < 0) return null; + const kind = attr.slice(0, separator); + const id = attr.slice(separator + 1); + if (kind === 'step') return { kind: 'step', id }; + if (kind === 'column') { + const directHostStepId = node.getAttribute('data-arrow-host-step-id'); + if (directHostStepId) return { kind: 'column', name: id, hostStepId: directHostStepId }; + let stepNode = node.parentElement; + while (stepNode) { + const hostStepId = stepNode.getAttribute('data-arrow-host-step-id'); + if (hostStepId) return { kind: 'column', name: id, hostStepId }; + const stepAttr = stepNode.getAttribute('data-arrow-endpoint'); + if (stepAttr?.startsWith('step:')) { + return { kind: 'column', name: id, hostStepId: stepAttr.slice(5) }; + } + stepNode = stepNode.parentElement; + } + } + } + node = node.parentElement; + } + return null; + }, []); + + const endpointFromPointerEvent = useCallback( + (event: PointerEvent): ArrowEndpoint | null => { + const targetElement = endpointElementFromTarget(event.target); + const fallbackElement = + typeof document === 'undefined' || typeof document.elementFromPoint !== 'function' + ? null + : document.elementFromPoint(event.clientX, event.clientY); + return parseEndpointElement(targetElement) ?? parseEndpointElement(fallbackElement); + }, + [endpointElementFromTarget, parseEndpointElement] + ); + + const endpointFromKeyboardEvent = useCallback( + ( + event: KeyboardEvent + ): { endpoint: ArrowEndpoint; at: { x: number; y: number } } | null => { + const element = endpointElementFromTarget(event.target); + const endpoint = parseEndpointElement(element); + if (!endpoint) return null; + const elementRect = element?.getBoundingClientRect(); + const surfaceRect = cardSurfaceRef.current?.getBoundingClientRect(); + if (elementRect && surfaceRect) { + return { + endpoint, + at: { + x: elementRect.left + elementRect.width / 2 - surfaceRect.left, + y: elementRect.top + elementRect.height / 2 - surfaceRect.top, + }, + }; + } + return { endpoint, at: { x: 0, y: 0 } }; + }, + [cardSurfaceRef, endpointElementFromTarget, parseEndpointElement] + ); + + const handlePointerDown = useCallback( + (event: PointerEvent): void => { + if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; + const endpoint = endpointFromPointerEvent(event); + if (!endpoint) return; + const sourceElement = endpointElementFromTarget(event.target); + const sourceRect = sourceElement?.getBoundingClientRect(); + const surfaceRect = cardSurfaceRef.current?.getBoundingClientRect(); + const anchor = + sourceRect && surfaceRect + ? { + x: sourceRect.left + sourceRect.width / 2 - surfaceRect.left, + y: sourceRect.top + sourceRect.height / 2 - surfaceRect.top, + } + : surfacePoint(event.clientX, event.clientY); + event.preventDefault(); + drawTool.onPointerDown(endpoint, anchor); + }, + [ + activeCanvasTool, + cardSurfaceRef, + disabled, + drawTool, + endpointElementFromTarget, + endpointFromPointerEvent, + surfacePoint, + ] + ); + + const handlePointerMove = useCallback( + (event: PointerEvent): void => { + if (drawTool.state.phase !== 'drawing') return; + drawTool.onPointerMove( + surfacePoint(event.clientX, event.clientY), + endpointFromPointerEvent(event) + ); + }, + [drawTool, endpointFromPointerEvent, surfacePoint] + ); + + const handlePointerUp = useCallback( + (event: PointerEvent): void => { + if (drawTool.state.phase !== 'drawing') return; + drawTool.onPointerUp( + endpointFromPointerEvent(event), + surfacePoint(event.clientX, event.clientY) + ); + }, + [drawTool, endpointFromPointerEvent, surfacePoint] + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent): void => { + if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; + if (event.key === 'Escape') { + drawTool.cancel(); + onCanvasToolChange?.('select'); + return; + } + if (event.key !== 'Enter' && event.key !== ' ') return; + const resolved = endpointFromKeyboardEvent(event); + if (!resolved) return; + event.preventDefault(); + if (drawTool.state.phase === 'drawing') { + drawTool.onPointerUp(resolved.endpoint, resolved.at); + } else { + drawTool.onPointerDown(resolved.endpoint, resolved.at); + } + }, + [activeCanvasTool, disabled, drawTool, endpointFromKeyboardEvent, onCanvasToolChange] + ); + + const endpointLabel = useCallback( + (endpoint: ArrowEndpoint): string => + endpoint.kind === 'column' ? endpoint.name : (stepMetricColumns[endpoint.id] ?? endpoint.id), + [stepMetricColumns] + ); + + // Reset draw tool when the active tool changes away from draw-hypothesis + useEffect(() => { + if (activeCanvasTool !== 'draw-hypothesis') drawTool.reset(); + }, [activeCanvasTool, drawTool]); + + return { + handlers: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onKeyDown: handleKeyDown, + }, + endpointLabel, + parseEndpointElement, + }; +} diff --git a/packages/hooks/src/useCanvasViewportInput.ts b/packages/hooks/src/useCanvasViewportInput.ts index ac931d1ef..826445aca 100644 --- a/packages/hooks/src/useCanvasViewportInput.ts +++ b/packages/hooks/src/useCanvasViewportInput.ts @@ -2,7 +2,8 @@ import { useEffect, useRef, type RefObject } from 'react'; import { select } from 'd3-selection'; import 'd3-transition'; // augments Selection with .transition() import { zoom, zoomIdentity, type D3ZoomEvent } from 'd3-zoom'; -import { useCanvasViewportStore, type ProcessHubId } from '@variscout/stores'; +import { useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { LOD_SNAP_BOUNDARIES, LOD_THRESHOLDS } from '@variscout/core/canvas'; export const SNAP_EASE_DURATION_MS = 150; @@ -32,7 +33,8 @@ type D3ZoomManagedElement = (HTMLElement | SVGSVGElement) & { }; export interface UseCanvasViewportInputOptions { - hubId: ProcessHubId; + /** Hub to bind zoom/pan input to. When null, the hook is a no-op (equivalent to disabled=true). */ + hubId: ProcessHubId | null; ref: RefObject; scaleExtent?: [number, number]; disabled?: boolean; @@ -58,11 +60,13 @@ export function useCanvasViewportInput({ useEffect(() => { const element = ref.current; - if (!element || disabled) return undefined; + // hubId null short-circuits: no hub to track, equivalent to disabled + if (!element || disabled || !hubId) return undefined; + const boundHubId: ProcessHubId = hubId; const selection = select(element); const syncElementToStoreViewport = () => { - const viewport = useCanvasViewportStore.getState().getViewport(hubId); + const viewport = useCanvasViewportStore.getState().getViewport(boundHubId); const desiredTransform = zoomIdentity .translate(viewport.pan.x, viewport.pan.y) .scale(viewport.zoom); @@ -91,8 +95,8 @@ export function useCanvasViewportInput({ syncingFromD3Ref.current = true; try { - setZoom(hubId, event.transform.k); - setPan(hubId, { x: event.transform.x, y: event.transform.y }); + setZoom(boundHubId, event.transform.k); + setPan(boundHubId, { x: event.transform.x, y: event.transform.y }); } finally { syncingFromD3Ref.current = false; } @@ -124,10 +128,10 @@ export function useCanvasViewportInput({ // Subscribe to the full store but short-circuit on reference equality of the // hub's viewport slice — avoids running syncElementToStoreViewport on every // unrelated mutation (e.g. setRailOpen, setViewMode, openChartCluster). - let prevViewportRef = useCanvasViewportStore.getState().viewports[hubId]; + let prevViewportRef = useCanvasViewportStore.getState().viewports[boundHubId]; const unsubscribe = useCanvasViewportStore.subscribe(state => { if (syncingFromD3Ref.current) return; - const nextViewport = state.viewports[hubId]; + const nextViewport = state.viewports[boundHubId]; if (nextViewport === prevViewportRef) return; prevViewportRef = nextViewport; syncElementToStoreViewport(); diff --git a/packages/hooks/src/useCanvasViewportShortcuts.ts b/packages/hooks/src/useCanvasViewportShortcuts.ts index 08f528973..b2713958e 100644 --- a/packages/hooks/src/useCanvasViewportShortcuts.ts +++ b/packages/hooks/src/useCanvasViewportShortcuts.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; -import { useCanvasViewportStore, type ProcessHubId } from '@variscout/stores'; +import { useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import type { CanvasLevel } from '@variscout/core/canvas'; function isEditableTarget(target: EventTarget | null): boolean { diff --git a/packages/hooks/src/useSharedWallProps.ts b/packages/hooks/src/useSharedWallProps.ts index bd47e7be1..460f55d3e 100644 --- a/packages/hooks/src/useSharedWallProps.ts +++ b/packages/hooks/src/useSharedWallProps.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; import { useCanvasViewportStore, useInvestigationStore } from '@variscout/stores'; import type { Finding, Question, Hypothesis } from '@variscout/core'; +import type { ProcessHubId } from '@variscout/core/processHub'; import type { ProcessMap } from '@variscout/core/frame'; const DEFAULT_WALL_PAN = { x: 0, y: 0 }; export interface UseSharedWallPropsArgs { - hubId: string; + hubId: ProcessHubId; findings: Finding[]; processMap: ProcessMap | undefined; problemCpk: number; diff --git a/packages/hooks/src/vite-env.d.ts b/packages/hooks/src/vite-env.d.ts new file mode 100644 index 000000000..2e911a947 --- /dev/null +++ b/packages/hooks/src/vite-env.d.ts @@ -0,0 +1,30 @@ +/** + * Minimal ImportMeta augmentation for packages that use import.meta.env / import.meta.glob + * but do not have `vite` as a direct dependency (and therefore cannot use + * `/// `). + * + * Covers the call-sites in this package: + * - import.meta.env.DEV (usePopoutChannel.ts) + * - import.meta.glob(...) (__tests__/useLocaleState.test.ts, __tests__/useTranslation.test.ts) + */ + +interface ImportMetaEnv { + readonly DEV: boolean; + readonly PROD: boolean; + readonly MODE: string; + [key: string]: string | boolean | undefined; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; + // Eager mode — returns the module directly + glob( + pattern: string | string[], + options: { eager: true; as?: string } + ): Record; + // Lazy mode (default) — returns a dynamic import factory + glob( + pattern: string | string[], + options?: { eager?: false; as?: string } + ): Record Promise>; +} diff --git a/packages/stores/src/__tests__/canvasViewportStore.test.ts b/packages/stores/src/__tests__/canvasViewportStore.test.ts index 5f65e215d..a5972c202 100644 --- a/packages/stores/src/__tests__/canvasViewportStore.test.ts +++ b/packages/stores/src/__tests__/canvasViewportStore.test.ts @@ -6,8 +6,17 @@ import { persistCanvasViewport, rehydrateCanvasViewport, deleteLegacyWallLayoutDb, + type ProcessHubId, } from '../canvasViewportStore'; +// Typed hub ID constants for test fixtures (cast acceptable inside test files per project convention) +const HUB_A = 'hub-A' as ProcessHubId; +const HUB_B = 'hub-B' as ProcessHubId; +const HUB_1 = 'hub-1' as ProcessHubId; +const HUB_2 = 'hub-2' as ProcessHubId; +const HUB_LEGACY = 'hub-legacy-clean-break' as ProcessHubId; +const HUB_SELECTION = 'hub-selection-boundary' as ProcessHubId; + describe('canvasViewportStore', () => { beforeEach(() => { useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); @@ -25,7 +34,7 @@ describe('canvasViewportStore', () => { }); it('returns a default viewport for an unknown hub', () => { - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toEqual({ zoom: 1, pan: { x: 0, y: 0 }, currentLevel: 'l2', @@ -35,48 +44,48 @@ describe('canvasViewportStore', () => { }); it('updates pan and zoom per hub', () => { - useCanvasViewportStore.getState().setPan('hub-A', { x: 100, y: -50 }); - useCanvasViewportStore.getState().setZoom('hub-A', 2); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 100, y: -50 }); + useCanvasViewportStore.getState().setZoom(HUB_A, 2); - expect(useCanvasViewportStore.getState().getViewport('hub-A').pan).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A).pan).toEqual({ x: 100, y: -50, }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(2); - expect(useCanvasViewportStore.getState().getViewport('hub-B').zoom).toBe(1); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).zoom).toBe(2); + expect(useCanvasViewportStore.getState().getViewport(HUB_B).zoom).toBe(1); }); it('setZoom syncs currentLevel from zoom and permits placeholder l3 without focalStepId', () => { - useCanvasViewportStore.getState().setZoom('hub-A', 0.2); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + useCanvasViewportStore.getState().setZoom(HUB_A, 0.2); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 0.2, currentLevel: 'l1', }); - useCanvasViewportStore.getState().setZoom('hub-A', 1); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + useCanvasViewportStore.getState().setZoom(HUB_A, 1); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 1, currentLevel: 'l2', }); - useCanvasViewportStore.getState().setZoom('hub-A', 2.5); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + useCanvasViewportStore.getState().setZoom(HUB_A, 2.5); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 2.5, currentLevel: 'l3', }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').focalStepId).toBeUndefined(); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).focalStepId).toBeUndefined(); }); it('setZoom clears focalStepId when zoom leaves l3', () => { - useCanvasViewportStore.getState().setLevel('hub-A', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l3', 'step-1'); - useCanvasViewportStore.getState().setZoom('hub-A', 1); + useCanvasViewportStore.getState().setZoom(HUB_A, 1); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 1, currentLevel: 'l2', }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').focalStepId).toBeUndefined(); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).focalStepId).toBeUndefined(); }); it('rail is open by default', () => { @@ -91,16 +100,16 @@ describe('canvasViewportStore', () => { }); it('setGroupByTributary toggles the per-hub flag', () => { - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); - expect(useCanvasViewportStore.getState().getViewport('hub-A').groupByTributary).toBe(true); - expect(useCanvasViewportStore.getState().getViewport('hub-B').groupByTributary).toBe(false); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', false); - expect(useCanvasViewportStore.getState().getViewport('hub-A').groupByTributary).toBe(false); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, true); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).groupByTributary).toBe(true); + expect(useCanvasViewportStore.getState().getViewport(HUB_B).groupByTributary).toBe(false); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, false); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).groupByTributary).toBe(false); }); it('groupByTributary changes do NOT populate undoStack (UI-only)', () => { - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', false); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, true); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, false); expect(useCanvasViewportStore.getState().undoStack.length).toBe(0); }); }); @@ -111,53 +120,53 @@ describe('canvasViewportStore — levels, positions, selection, cache', () => { }); it('sets node position per hub', () => { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 500, y: 400 }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1']).toEqual({ + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 500, y: 400 }); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1']).toEqual({ x: 500, y: 400, }); expect( - useCanvasViewportStore.getState().getViewport('hub-B').nodePositions['node-1'] + useCanvasViewportStore.getState().getViewport(HUB_B).nodePositions['node-1'] ).toBeUndefined(); }); it('sets level with l3 focalStepId validation and clears stale focalStepId on l1/l2', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - useCanvasViewportStore.getState().setLevel('hub-A', 'l1'); - expect(useCanvasViewportStore.getState().getViewport('hub-A').currentLevel).toBe('l1'); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l1'); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).currentLevel).toBe('l1'); - useCanvasViewportStore.getState().setLevel('hub-A', 'l3', 'step-1'); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + useCanvasViewportStore.getState().setLevel(HUB_A, 'l3', 'step-1'); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', }); // l3 without focalStepId: warns and leaves state unchanged (no-op, no throw). - const levelBefore = useCanvasViewportStore.getState().getViewport('hub-B').currentLevel; - useCanvasViewportStore.getState().setLevel('hub-B', 'l3'); + const levelBefore = useCanvasViewportStore.getState().getViewport(HUB_B).currentLevel; + useCanvasViewportStore.getState().setLevel(HUB_B, 'l3'); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('l3 requested without focalStepId') ); - expect(useCanvasViewportStore.getState().getViewport('hub-B').currentLevel).toBe(levelBefore); + expect(useCanvasViewportStore.getState().getViewport(HUB_B).currentLevel).toBe(levelBefore); warnSpy.mockRestore(); - useCanvasViewportStore.getState().setLevel('hub-A', 'l2'); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + useCanvasViewportStore.getState().setLevel(HUB_A, 'l2'); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ currentLevel: 'l2', }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').focalStepId).toBeUndefined(); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).focalStepId).toBeUndefined(); }); it('fitToContent applies placeholder zoom, resets pan, and preserves layout flags', () => { - useCanvasViewportStore.getState().setPan('hub-A', { x: 100, y: -50 }); - useCanvasViewportStore.getState().setZoom('hub-A', 1.5); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 20 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 100, y: -50 }); + useCanvasViewportStore.getState().setZoom(HUB_A, 1.5); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 20 }); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, true); - useCanvasViewportStore.getState().fitToContent('hub-A', 'l1'); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toEqual({ + useCanvasViewportStore.getState().fitToContent(HUB_A, 'l1'); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toEqual({ zoom: 0.2, pan: { x: 0, y: 0 }, currentLevel: 'l1', @@ -165,8 +174,8 @@ describe('canvasViewportStore — levels, positions, selection, cache', () => { groupByTributary: true, }); - useCanvasViewportStore.getState().fitToContent('hub-B'); - expect(useCanvasViewportStore.getState().getViewport('hub-B')).toEqual({ + useCanvasViewportStore.getState().fitToContent(HUB_B); + expect(useCanvasViewportStore.getState().getViewport(HUB_B)).toEqual({ zoom: 1, pan: { x: 0, y: 0 }, currentLevel: 'l2', @@ -176,12 +185,12 @@ describe('canvasViewportStore — levels, positions, selection, cache', () => { }); it('fitToContent uses current level when no explicit level is provided', () => { - useCanvasViewportStore.getState().setLevel('hub-A', 'l1'); - useCanvasViewportStore.getState().setPan('hub-A', { x: 9, y: 8 }); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l1'); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 9, y: 8 }); - useCanvasViewportStore.getState().fitToContent('hub-A'); + useCanvasViewportStore.getState().fitToContent(HUB_A); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 0.2, pan: { x: 0, y: 0 }, currentLevel: 'l1', @@ -189,24 +198,24 @@ describe('canvasViewportStore — levels, positions, selection, cache', () => { }); it('fitToContent falls back to l2 for bare fit from placeholder l3 without focalStepId', () => { - useCanvasViewportStore.getState().setZoom('hub-A', 2.5); + useCanvasViewportStore.getState().setZoom(HUB_A, 2.5); - expect(() => useCanvasViewportStore.getState().fitToContent('hub-A')).not.toThrow(); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + expect(() => useCanvasViewportStore.getState().fitToContent(HUB_A)).not.toThrow(); + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 1, pan: { x: 0, y: 0 }, currentLevel: 'l2', }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').focalStepId).toBeUndefined(); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).focalStepId).toBeUndefined(); }); it('fitToContent l3 requires and preserves an existing focalStepId', () => { - useCanvasViewportStore.getState().setLevel('hub-A', 'l3', 'step-1'); - useCanvasViewportStore.getState().setPan('hub-A', { x: 9, y: 8 }); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l3', 'step-1'); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 9, y: 8 }); - useCanvasViewportStore.getState().fitToContent('hub-A', 'l3'); + useCanvasViewportStore.getState().fitToContent(HUB_A, 'l3'); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toMatchObject({ zoom: 2.5, pan: { x: 0, y: 0 }, currentLevel: 'l3', @@ -215,36 +224,36 @@ describe('canvasViewportStore — levels, positions, selection, cache', () => { // fitToContent with explicit l3 on a hub with no focalStepId: warns, leaves viewport unchanged. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const snapshotBefore = useCanvasViewportStore.getState().getViewport('hub-B'); - useCanvasViewportStore.getState().fitToContent('hub-B', 'l3'); + const snapshotBefore = useCanvasViewportStore.getState().getViewport(HUB_B); + useCanvasViewportStore.getState().fitToContent(HUB_B, 'l3'); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('l3 requested without focalStepId') ); - expect(useCanvasViewportStore.getState().getViewport('hub-B')).toEqual(snapshotBefore); + expect(useCanvasViewportStore.getState().getViewport(HUB_B)).toEqual(snapshotBefore); warnSpy.mockRestore(); }); it('keeps multiple hubs independent', () => { - useCanvasViewportStore.getState().setZoom('hub-A', 1.5); - useCanvasViewportStore.getState().setPan('hub-A', { x: 10, y: 20 }); - useCanvasViewportStore.getState().setLevel('hub-A', 'l1'); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 1, y: 2 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); - - useCanvasViewportStore.getState().setZoom('hub-B', 2.5); - useCanvasViewportStore.getState().setPan('hub-B', { x: -10, y: -20 }); - useCanvasViewportStore.getState().setLevel('hub-B', 'l3', 'step-9'); - useCanvasViewportStore.getState().setNodePosition('hub-B', 'node-1', { x: 9, y: 8 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-B', false); - - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toEqual({ + useCanvasViewportStore.getState().setZoom(HUB_A, 1.5); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 10, y: 20 }); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l1'); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 1, y: 2 }); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, true); + + useCanvasViewportStore.getState().setZoom(HUB_B, 2.5); + useCanvasViewportStore.getState().setPan(HUB_B, { x: -10, y: -20 }); + useCanvasViewportStore.getState().setLevel(HUB_B, 'l3', 'step-9'); + useCanvasViewportStore.getState().setNodePosition(HUB_B, 'node-1', { x: 9, y: 8 }); + useCanvasViewportStore.getState().setGroupByTributary(HUB_B, false); + + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toEqual({ zoom: 1.5, pan: { x: 10, y: 20 }, currentLevel: 'l1', nodePositions: { 'node-1': { x: 1, y: 2 } }, groupByTributary: true, }); - expect(useCanvasViewportStore.getState().getViewport('hub-B')).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_B)).toEqual({ zoom: 2.5, pan: { x: -10, y: -20 }, currentLevel: 'l3', @@ -337,17 +346,15 @@ describe('canvasViewportStore persistence', () => { // Hub-keyed persistence must still work correctly. useCanvasViewportStore.getState().setViewMode('wall'); - useCanvasViewportStore.getState().setZoom('hub-legacy-clean-break', 1.75); - useCanvasViewportStore - .getState() - .setNodePosition('hub-legacy-clean-break', 'node-1', { x: 10, y: 20 }); + useCanvasViewportStore.getState().setZoom(HUB_LEGACY, 1.75); + useCanvasViewportStore.getState().setNodePosition(HUB_LEGACY, 'node-1', { x: 10, y: 20 }); - await expect(persistCanvasViewport('hub-legacy-clean-break')).resolves.toBeUndefined(); + await expect(persistCanvasViewport(HUB_LEGACY)).resolves.toBeUndefined(); useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); - await expect(rehydrateCanvasViewport('hub-legacy-clean-break')).resolves.toBeUndefined(); + await expect(rehydrateCanvasViewport(HUB_LEGACY)).resolves.toBeUndefined(); expect(useCanvasViewportStore.getState().viewMode).toBe('wall'); - expect(useCanvasViewportStore.getState().getViewport('hub-legacy-clean-break')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(HUB_LEGACY)).toMatchObject({ zoom: 1.75, nodePositions: { 'node-1': { x: 10, y: 20 } }, }); @@ -368,21 +375,21 @@ describe('canvasViewportStore persistence', () => { it('persists and rehydrates one hub viewport with viewMode and railOpen', async () => { useCanvasViewportStore.getState().setViewMode('wall'); useCanvasViewportStore.getState().setRailOpen(false); - useCanvasViewportStore.getState().setZoom('hub-A', 2.25); - useCanvasViewportStore.getState().setPan('hub-A', { x: 123, y: 456 }); - useCanvasViewportStore.getState().setLevel('hub-A', 'l3', 'step-7'); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 20 }); - useCanvasViewportStore.getState().setGroupByTributary('hub-A', true); - await persistCanvasViewport('hub-A'); + useCanvasViewportStore.getState().setZoom(HUB_A, 2.25); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 123, y: 456 }); + useCanvasViewportStore.getState().setLevel(HUB_A, 'l3', 'step-7'); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 20 }); + useCanvasViewportStore.getState().setGroupByTributary(HUB_A, true); + await persistCanvasViewport(HUB_A); useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); expect(useCanvasViewportStore.getState().viewMode).toBe('map'); expect(useCanvasViewportStore.getState().railOpen).toBe(true); - await rehydrateCanvasViewport('hub-A'); + await rehydrateCanvasViewport(HUB_A); expect(useCanvasViewportStore.getState().viewMode).toBe('wall'); expect(useCanvasViewportStore.getState().railOpen).toBe(false); - expect(useCanvasViewportStore.getState().getViewport('hub-A')).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A)).toEqual({ zoom: 2.25, pan: { x: 123, y: 456 }, currentLevel: 'l3', @@ -395,43 +402,44 @@ describe('canvasViewportStore persistence', () => { it('does not apply rehydrate snapshot when guard returns false', async () => { useCanvasViewportStore.getState().setViewMode('wall'); useCanvasViewportStore.getState().setRailOpen(false); - useCanvasViewportStore.getState().setZoom('hub-A', 2.25); - await persistCanvasViewport('hub-A'); + useCanvasViewportStore.getState().setZoom(HUB_A, 2.25); + await persistCanvasViewport(HUB_A); useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); - await rehydrateCanvasViewport('hub-A', () => false); + await rehydrateCanvasViewport(HUB_A, () => false); expect(useCanvasViewportStore.getState().viewMode).toBe('map'); expect(useCanvasViewportStore.getState().railOpen).toBe(true); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).zoom).toBe(1); }); it('persists and rehydrates hubs independently', async () => { - useCanvasViewportStore.getState().setZoom('hub-A', 1.25); - await persistCanvasViewport('hub-A'); + useCanvasViewportStore.getState().setZoom(HUB_A, 1.25); + await persistCanvasViewport(HUB_A); - useCanvasViewportStore.getState().setZoom('hub-B', 3); - useCanvasViewportStore.getState().setPan('hub-B', { x: 30, y: 40 }); - await persistCanvasViewport('hub-B'); + useCanvasViewportStore.getState().setZoom(HUB_B, 3); + useCanvasViewportStore.getState().setPan(HUB_B, { x: 30, y: 40 }); + await persistCanvasViewport(HUB_B); useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); - await rehydrateCanvasViewport('hub-A'); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1.25); - expect(useCanvasViewportStore.getState().getViewport('hub-B').zoom).toBe(1); + await rehydrateCanvasViewport(HUB_A); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).zoom).toBe(1.25); + expect(useCanvasViewportStore.getState().getViewport(HUB_B).zoom).toBe(1); useCanvasViewportStore.setState(useCanvasViewportStore.getInitialState()); - await rehydrateCanvasViewport('hub-B'); - expect(useCanvasViewportStore.getState().getViewport('hub-B')).toMatchObject({ + await rehydrateCanvasViewport(HUB_B); + expect(useCanvasViewportStore.getState().getViewport(HUB_B)).toMatchObject({ zoom: 3, pan: { x: 30, y: 40 }, }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').zoom).toBe(1); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).zoom).toBe(1); }); it('rehydrate with unknown hub leaves defaults', async () => { - await rehydrateCanvasViewport('unknown-hub'); + const unknownHub = 'unknown-hub' as ProcessHubId; + await rehydrateCanvasViewport(unknownHub); expect(useCanvasViewportStore.getState().viewMode).toBe('map'); - expect(useCanvasViewportStore.getState().getViewport('unknown-hub')).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(unknownHub)).toEqual({ zoom: 1, pan: { x: 0, y: 0 }, currentLevel: 'l2', @@ -447,27 +455,27 @@ describe('canvasViewportStore — undo/redo', () => { }); it('records setNodePosition on the undo stack', () => { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 20 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 20 }); expect(useCanvasViewportStore.getState().undoStack.length).toBe(1); expect(useCanvasViewportStore.getState().redoStack.length).toBe(0); }); it('round-trip: move -> undo -> revert -> redo -> restore', () => { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 20 }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1']).toEqual({ + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 20 }); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1']).toEqual({ x: 10, y: 20, }); useCanvasViewportStore.getState().undo(); expect( - useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1'] + useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1'] ).toBeUndefined(); expect(useCanvasViewportStore.getState().undoStack.length).toBe(0); expect(useCanvasViewportStore.getState().redoStack.length).toBe(1); useCanvasViewportStore.getState().redo(); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1']).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1']).toEqual({ x: 10, y: 20, }); @@ -476,54 +484,54 @@ describe('canvasViewportStore — undo/redo', () => { }); it('undo of sequential moves reverts the latest first', () => { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 10 }); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 50, y: 50 }); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 99, y: 99 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 10 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 50, y: 50 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 99, y: 99 }); useCanvasViewportStore.getState().undo(); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1']).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1']).toEqual({ x: 50, y: 50, }); useCanvasViewportStore.getState().undo(); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1']).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1']).toEqual({ x: 10, y: 10, }); useCanvasViewportStore.getState().undo(); expect( - useCanvasViewportStore.getState().getViewport('hub-A').nodePositions['node-1'] + useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions['node-1'] ).toBeUndefined(); }); it('caps undoStack at 50 entries (60 sequential changes keep <= 50)', () => { for (let i = 0; i < 60; i++) { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: i, y: i }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: i, y: i }); } expect(useCanvasViewportStore.getState().undoStack.length).toBeLessThanOrEqual(50); expect(useCanvasViewportStore.getState().undoStack.length).toBe(50); }); it('new mutation after undo clears the redo stack', () => { - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-1', { x: 10, y: 20 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-1', { x: 10, y: 20 }); useCanvasViewportStore.getState().undo(); expect(useCanvasViewportStore.getState().redoStack.length).toBe(1); - useCanvasViewportStore.getState().setNodePosition('hub-A', 'node-2', { x: 99, y: 99 }); + useCanvasViewportStore.getState().setNodePosition(HUB_A, 'node-2', { x: 99, y: 99 }); expect(useCanvasViewportStore.getState().redoStack.length).toBe(0); }); it('zoom changes do NOT populate undoStack', () => { - useCanvasViewportStore.getState().setZoom('hub-A', 2.5); - useCanvasViewportStore.getState().setZoom('hub-A', 0.8); + useCanvasViewportStore.getState().setZoom(HUB_A, 2.5); + useCanvasViewportStore.getState().setZoom(HUB_A, 0.8); expect(useCanvasViewportStore.getState().undoStack.length).toBe(0); }); it('pan changes do NOT populate undoStack', () => { - useCanvasViewportStore.getState().setPan('hub-A', { x: 100, y: 100 }); - useCanvasViewportStore.getState().setPan('hub-A', { x: -50, y: -50 }); + useCanvasViewportStore.getState().setPan(HUB_A, { x: 100, y: 100 }); + useCanvasViewportStore.getState().setPan(HUB_A, { x: -50, y: -50 }); expect(useCanvasViewportStore.getState().undoStack.length).toBe(0); }); @@ -561,7 +569,7 @@ describe('canvasViewportStore — undo/redo', () => { it('applyWithUndo round-trips an arbitrary mutation', () => { useCanvasViewportStore.getState().applyWithUndo(draft => { - draft.viewports['hub-A'] = { + draft.viewports[HUB_A] = { zoom: 1, pan: { x: 0, y: 0 }, currentLevel: 'l2', @@ -572,12 +580,12 @@ describe('canvasViewportStore — undo/redo', () => { groupByTributary: false, }; }); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions).toEqual({ + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions).toEqual({ x: { x: 1, y: 1 }, y: { x: 2, y: 2 }, }); useCanvasViewportStore.getState().undo(); - expect(useCanvasViewportStore.getState().getViewport('hub-A').nodePositions).toEqual({}); + expect(useCanvasViewportStore.getState().getViewport(HUB_A).nodePositions).toEqual({}); }); }); @@ -587,10 +595,10 @@ describe('canvasViewportStore — selection persistence boundary', () => { }); it('does NOT persist selection', async () => { - const hubId = 'hub-selection-boundary'; + const hubId = HUB_SELECTION; - useCanvasViewportStore.getState().setSelection(['hub-1', 'hub-2']); - expect([...useCanvasViewportStore.getState().selection]).toEqual(['hub-1', 'hub-2']); + useCanvasViewportStore.getState().setSelection([HUB_1, HUB_2]); + expect([...useCanvasViewportStore.getState().selection]).toEqual([HUB_1, HUB_2]); await persistCanvasViewport(hubId); diff --git a/packages/stores/src/canvasViewportStore.ts b/packages/stores/src/canvasViewportStore.ts index ecb4f394d..a7580bf56 100644 --- a/packages/stores/src/canvasViewportStore.ts +++ b/packages/stores/src/canvasViewportStore.ts @@ -7,8 +7,8 @@ */ // R12 exception: separate Dexie DB for cross-app canvas viewport UI state. -import type { ProcessHub } from '@variscout/core'; import { inferLevel, FIT_TO_CONTENT_ZOOM_BY_LEVEL, type CanvasLevel } from '@variscout/core/canvas'; +import type { ProcessHubId } from '@variscout/core/processHub'; import Dexie, { type Table } from 'dexie'; import { applyPatches, enablePatches, produceWithPatches, type Patch } from 'immer'; import { create } from 'zustand'; @@ -18,8 +18,6 @@ export const STORE_LAYER = 'annotation-per-hub' as const; enablePatches(); const UNDO_STACK_CAP = 50; - -export type ProcessHubId = ProcessHub['id']; export type NodeId = string; export type TributaryId = string; export type GateNodePath = string; diff --git a/packages/stores/src/index.ts b/packages/stores/src/index.ts index bc278fddd..d67050bf0 100644 --- a/packages/stores/src/index.ts +++ b/packages/stores/src/index.ts @@ -47,7 +47,6 @@ export type { ChartClusterState, AndCheckSnapshot, PendingComment, - ProcessHubId, NodeId, TributaryId, GateNodePath, diff --git a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx index dc585bf87..feaff566c 100644 --- a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx +++ b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx @@ -18,6 +18,7 @@ import { type DataRow, type Finding, type ProcessContext, + type ProcessHubId, type ProcessHubInvestigation, type Question, type SpecLimits, @@ -76,7 +77,7 @@ export interface CanvasWorkspaceProps { eventsPerWeek?: number; activeColumns?: ReadonlyArray; onOpenWall?: () => void; - onOpenScout?: (hubId: string) => void; + onOpenScout?: (hubId: ProcessHubId) => void; onAddCausalLink?: ( fromFactor: string, toFactor: string, @@ -105,8 +106,8 @@ function toggleArray(arr: readonly T[], item: T): T[] { } function fitViewportNowAndAfterRender( - fitToContent: (hubId: string, targetLevel?: CanvasLevel) => void, - hubId: string, + fitToContent: (hubId: ProcessHubId, targetLevel?: CanvasLevel) => void, + hubId: ProcessHubId, targetLevel: CanvasLevel ): void { fitToContent(hubId, targetLevel); diff --git a/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx index 304913a6c..0d131d1af 100644 --- a/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx +++ b/packages/ui/src/components/Canvas/__tests__/Canvas.test.tsx @@ -75,8 +75,12 @@ import { useCanvasViewportStore, useInvestigationStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { Canvas } from '../index'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + const SIGNALS = { hasIntervention: false, sustainmentConfirmed: false }; const map: ProcessMap = { @@ -186,11 +190,11 @@ const investigationOverlays: CanvasInvestigationOverlayModel = { ], hypotheses: [ { - id: 'hub-1', + id: h('hub-1'), name: 'Pressure setup drift', status: 'proposed', questionId: 'q-1', - focus: { kind: 'suspected-cause', id: 'hub-1', questionId: 'q-1' }, + focus: { kind: 'suspected-cause', id: h('hub-1'), questionId: 'q-1' }, }, ], causalLinks: [ @@ -307,10 +311,10 @@ describe('Canvas', () => { }); it('wraps the L2 card surface content in the current hub viewport transform', () => { - useCanvasViewportStore.getState().setPan('hub-l2-canvas', { x: 48, y: -24 }); - useCanvasViewportStore.getState().setZoom('hub-l2-canvas', 1.75); + useCanvasViewportStore.getState().setPan(h('hub-l2-canvas'), { x: 48, y: -24 }); + useCanvasViewportStore.getState().setZoom(h('hub-l2-canvas'), 1.75); - renderCanvas({ hubId: 'hub-l2-canvas' }); + renderCanvas({ hubId: h('hub-l2-canvas') }); expect( screen.getByTestId('canvas-card-surface').querySelector('[data-canvas-viewport-wrapper]') @@ -323,9 +327,9 @@ describe('Canvas', () => { }); it('passes the current hub zoom to L2 step cards for overview detail', () => { - useCanvasViewportStore.getState().setZoom('hub-l2-overview', 0.75); + useCanvasViewportStore.getState().setZoom(h('hub-l2-overview'), 0.75); - renderCanvas({ hubId: 'hub-l2-overview' }); + renderCanvas({ hubId: h('hub-l2-overview') }); const card = screen.getByTestId('canvas-step-card-step-1'); expect(card).toHaveTextContent('Mix'); @@ -336,10 +340,10 @@ describe('Canvas', () => { }); it('renders the L1 system outcome panel when the hub viewport is at system level', () => { - useCanvasViewportStore.getState().setLevel('hub-l1-canvas', 'l1'); + useCanvasViewportStore.getState().setLevel(h('hub-l1-canvas'), 'l1'); renderCanvas({ - hubId: 'hub-l1-canvas', + hubId: h('hub-l1-canvas'), map: { ...mapWithSteps, ctsColumn: 'Fill Weight' }, rows: [{ 'Fill Weight': 100 }, { 'Fill Weight': 101 }], usl: 102, @@ -359,10 +363,10 @@ describe('Canvas', () => { }); it('renders an empty state for invalid lens and level cells', () => { - useCanvasViewportStore.getState().setLevel('hub-l1-yamazumi', 'l1'); + useCanvasViewportStore.getState().setLevel(h('hub-l1-yamazumi'), 'l1'); renderCanvas({ - hubId: 'hub-l1-yamazumi', + hubId: h('hub-l1-yamazumi'), activeLens: 'yamazumi', map: { ...mapWithSteps, ctsColumn: 'Fill Weight' }, rows: [{ 'Fill Weight': 100 }, { 'Fill Weight': 101 }], @@ -376,10 +380,10 @@ describe('Canvas', () => { }); it('renders an empty state for process-flow at L3 before mounting the level renderer', () => { - useCanvasViewportStore.getState().setLevel('hub-l3-flow', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-l3-flow'), 'l3', 'step-1'); renderCanvas({ - hubId: 'hub-l3-flow', + hubId: h('hub-l3-flow'), activeLens: 'process-flow', }); @@ -422,12 +426,12 @@ describe('Canvas', () => { }; try { - renderCanvas({ hubId: 'hub-measured-fit' }); + renderCanvas({ hubId: h('hub-measured-fit') }); fireEvent.keyDown(window, { key: '1', metaKey: true }); await act(() => new Promise(resolve => window.requestAnimationFrame(resolve))); - expect(useCanvasViewportStore.getState().getViewport('hub-measured-fit')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-measured-fit'))).toMatchObject({ currentLevel: 'l1', zoom: 1.9, pan: { x: 25, y: 12.5 }, @@ -438,7 +442,7 @@ describe('Canvas', () => { }); it('keeps the desktop LOD input surface mounted on L1 and can wheel back to L2', () => { - const hubId = 'hub-l1-wheel-recover'; + const hubId = h('hub-l1-wheel-recover'); useCanvasViewportStore.getState().fitToContent(hubId, 'l1'); renderCanvas({ hubId }); @@ -459,7 +463,7 @@ describe('Canvas', () => { }); it('falls back to the first ordered step when L3 has no focal step selected', async () => { - const hubId = 'hub-l3-fallback'; + const hubId = h('hub-l3-fallback'); useCanvasViewportStore.getState().setZoom(hubId, 2.5); renderCanvas({ @@ -484,11 +488,11 @@ describe('Canvas', () => { }); it('renders the L3 local mechanism view when a focal step is selected in read mode', () => { - useCanvasViewportStore.getState().setLevel('hub-l3-canvas', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-l3-canvas'), 'l3', 'step-1'); const rows = [{ Pressure: 10 }, { Pressure: 11 }]; renderCanvas({ - hubId: 'hub-l3-canvas', + hubId: h('hub-l3-canvas'), mode: 'read', rows, map: { ...mapWithSteps, ctsColumn: 'Defect' }, @@ -505,7 +509,7 @@ describe('Canvas', () => { ); expect(screen.queryByTestId('author-l3-view')).not.toBeInTheDocument(); expect(localMechanismPropsRef.current).toMatchObject({ - hubId: 'hub-l3-canvas', + hubId: h('hub-l3-canvas'), focalStepId: 'step-1', map: expect.any(Object), rows, @@ -516,10 +520,10 @@ describe('Canvas', () => { }); it('renders the author L3 view for direct Canvas author mode', () => { - useCanvasViewportStore.getState().setLevel('hub-l3-author-canvas', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-l3-author-canvas'), 'l3', 'step-1'); renderCanvas({ - hubId: 'hub-l3-author-canvas', + hubId: h('hub-l3-author-canvas'), mode: 'author', chips: [{ chipId: 'Bake_Time', label: 'Bake Time', role: 'factor' }], }); @@ -530,10 +534,10 @@ describe('Canvas', () => { it('supports keyboard chip pickup and drop in direct Canvas author L3', () => { const onPlaceChip = vi.fn(); - useCanvasViewportStore.getState().setLevel('hub-l3-author-keyboard', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-l3-author-keyboard'), 'l3', 'step-1'); renderCanvas({ - hubId: 'hub-l3-author-keyboard', + hubId: h('hub-l3-author-keyboard'), mode: 'author', chips: [{ chipId: 'Bake_Time', label: 'Bake Time', role: 'factor' }], onPlaceChip, @@ -549,7 +553,7 @@ describe('Canvas', () => { it('shows the mobile level picker and skips the d3 pan/zoom viewport on mobile', () => { wallIsMobileRef.current = true; - renderCanvas({ hubId: 'hub-mobile-canvas' }); + renderCanvas({ hubId: h('hub-mobile-canvas') }); expect(screen.getByTestId('mobile-level-picker')).toBeInTheDocument(); expect( @@ -558,7 +562,7 @@ describe('Canvas', () => { }); it('does not apply viewport shortcuts when Canvas is disabled', () => { - const hubId = 'hub-disabled-shortcuts'; + const hubId = h('hub-disabled-shortcuts'); renderCanvas({ hubId, disabled: true }); @@ -845,7 +849,7 @@ describe('Canvas', () => { }); it('does not let Wall overlay controls bubble into the L2 viewport input', () => { - const hubId = 'hub-wall-overlay-nested-input'; + const hubId = h('hub-wall-overlay-nested-input'); useInvestigationStore.getState().addQuestion('Does pressure explain defect clusters?'); renderCanvas({ @@ -1233,7 +1237,7 @@ describe('Canvas', () => { }); it('remeasures hypothesis arrows after the hub viewport transform changes', () => { - const hubId = 'hub-arrow-viewport'; + const hubId = h('hub-arrow-viewport'); const rectFor = (left: number, top: number, width: number, height: number): DOMRect => ({ x: left, @@ -1317,7 +1321,7 @@ describe('Canvas', () => { }); it('closes the L2 step overlay when leaving the process level', () => { - const hubId = 'hub-overlay-level-exit'; + const hubId = h('hub-overlay-level-exit'); renderCanvas({ hubId }); fireEvent.click(screen.getByTestId('canvas-step-card-step-1')); diff --git a/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx b/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx index 7d578d229..84feaa824 100644 --- a/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx +++ b/packages/ui/src/components/Canvas/__tests__/CanvasWorkspace.test.tsx @@ -25,6 +25,7 @@ import { useCanvasStore, useCanvasViewportStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; const wallIsMobileRef = vi.hoisted(() => ({ current: false })); const localMechanismPropsRef = vi.hoisted(() => ({ @@ -516,10 +517,107 @@ vi.mock('@variscout/hooks', () => ({ ), useSessionCanvasFilters: vi.fn(() => canvasFiltersStateRef.current), useCanvasViewportInput: vi.fn(), + useCanvasHypothesisDrawing: vi.fn( + ({ + activeCanvasTool, + disabled, + drawTool, + onCanvasToolChange, + stepMetricColumns, + }: { + activeCanvasTool: string; + disabled?: boolean; + drawTool: { + state: { phase: string; [k: string]: unknown }; + onPointerDown: (endpoint: unknown, at: unknown) => void; + onPointerMove: (at: unknown, hover: unknown) => void; + onPointerUp: (endpoint: unknown, at: unknown) => void; + cancel: () => void; + }; + cardSurfaceRef: { current: HTMLElement | null }; + onCanvasToolChange?: (next: string) => void; + stepMetricColumns: Record; + }) => ({ + handlers: { + onPointerDown: (event: React.PointerEvent) => { + if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; + const el = + event.target instanceof Element ? event.target.closest('[data-arrow-endpoint]') : null; + if (!el) return; + const attr = el.getAttribute('data-arrow-endpoint') ?? ''; + const sep = attr.indexOf(':'); + if (sep < 0) return; + const kind = attr.slice(0, sep); + const id = attr.slice(sep + 1); + const endpoint = + kind === 'step' ? { kind: 'step', id } : { kind: 'column', name: id, hostStepId: id }; + event.preventDefault(); + drawTool.onPointerDown(endpoint, { x: event.clientX, y: event.clientY }); + }, + onPointerMove: (event: React.PointerEvent) => { + if (drawTool.state.phase !== 'drawing') return; + const el = + event.target instanceof Element ? event.target.closest('[data-arrow-endpoint]') : null; + let hover = null; + if (el) { + const attr = el.getAttribute('data-arrow-endpoint') ?? ''; + const sep = attr.indexOf(':'); + if (sep >= 0) { + const kind = attr.slice(0, sep); + const id = attr.slice(sep + 1); + hover = + kind === 'step' + ? { kind: 'step', id } + : { kind: 'column', name: id, hostStepId: id }; + } + } + drawTool.onPointerMove({ x: event.clientX, y: event.clientY }, hover); + }, + onPointerUp: (event: React.PointerEvent) => { + if (drawTool.state.phase !== 'drawing') return; + const el = + event.target instanceof Element ? event.target.closest('[data-arrow-endpoint]') : null; + let endpoint = null; + if (el) { + const attr = el.getAttribute('data-arrow-endpoint') ?? ''; + const sep = attr.indexOf(':'); + if (sep >= 0) { + const kind = attr.slice(0, sep); + const id = attr.slice(sep + 1); + endpoint = + kind === 'step' + ? { kind: 'step', id } + : { kind: 'column', name: id, hostStepId: id }; + } + } + drawTool.onPointerUp(endpoint, { x: event.clientX, y: event.clientY }); + }, + onKeyDown: (event: React.KeyboardEvent) => { + if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; + if (event.key === 'Escape') { + drawTool.cancel(); + onCanvasToolChange?.('select'); + } + }, + }, + endpointLabel: (endpoint: { kind: string; id?: string; name?: string }) => { + if (endpoint.kind === 'column') return endpoint.name ?? ''; + return (endpoint.id ? stepMetricColumns[endpoint.id] : undefined) ?? endpoint.id ?? ''; + }, + parseEndpointElement: () => null, + }) + ), + useCanvasHypothesisArrows: vi.fn(() => ({ + arrowSegments: [], + registerCardElement: vi.fn(), + })), })); import { CanvasWorkspace } from '../CanvasWorkspace'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + const SIGNALS = { hasIntervention: false, sustainmentConfirmed: false }; const rawData = [ @@ -653,11 +751,13 @@ describe('CanvasWorkspace', () => { window.history.replaceState(null, '', '/?level=l1'); renderWorkspace({ - canvasViewportHubId: 'hub-url-level', + canvasViewportHubId: h('hub-url-level'), processContext: { processMap: mapWithStep() }, }); - expect(useCanvasViewportStore.getState().getViewport('hub-url-level').currentLevel).toBe('l1'); + expect(useCanvasViewportStore.getState().getViewport(h('hub-url-level')).currentLevel).toBe( + 'l1' + ); expect(screen.getByTestId('outcome-distribution')).toBeInTheDocument(); }); @@ -665,11 +765,11 @@ describe('CanvasWorkspace', () => { window.history.replaceState(null, '', '/?level=l3'); renderWorkspace({ - canvasViewportHubId: 'hub-url-l3-bare', + canvasViewportHubId: h('hub-url-l3-bare'), processContext: { processMap: mapWithStep() }, }); - expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-bare').currentLevel).toBe( + expect(useCanvasViewportStore.getState().getViewport(h('hub-url-l3-bare')).currentLevel).toBe( 'l2' ); expect(window.location.search).toBe('?level=l2'); @@ -679,11 +779,11 @@ describe('CanvasWorkspace', () => { window.history.replaceState(null, '', '/?level=l3&focalStep=step-1'); renderWorkspace({ - canvasViewportHubId: 'hub-url-l3-focal', + canvasViewportHubId: h('hub-url-l3-focal'), processContext: { processMap: mapWithStep() }, }); - expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-focal')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-url-l3-focal'))).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', }); @@ -726,14 +826,18 @@ describe('CanvasWorkspace', () => { render(); - expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-async-focal')).toMatchObject({ + expect( + useCanvasViewportStore.getState().getViewport(h('hub-url-l3-async-focal')) + ).toMatchObject({ currentLevel: 'l2', }); expect(window.location.search).toBe('?level=l3&focalStep=step-1'); fireEvent.click(screen.getByTestId('load-process-map')); - expect(useCanvasViewportStore.getState().getViewport('hub-url-l3-async-focal')).toMatchObject({ + expect( + useCanvasViewportStore.getState().getViewport(h('hub-url-l3-async-focal')) + ).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', }); @@ -758,10 +862,10 @@ describe('CanvasWorkspace', () => { }); it('threads raw rows from CanvasWorkspace into the read-mode L3 local mechanism view', () => { - useCanvasViewportStore.getState().setLevel('hub-workspace-l3', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-workspace-l3'), 'l3', 'step-1'); renderWorkspace({ - canvasViewportHubId: 'hub-workspace-l3', + canvasViewportHubId: h('hub-workspace-l3'), processContext: { processMap: readModeMapWithStep() }, }); @@ -773,10 +877,10 @@ describe('CanvasWorkspace', () => { }); it('routes author-mode L3 away from the local mechanism view', () => { - useCanvasViewportStore.getState().setLevel('hub-workspace-l3-author', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-workspace-l3-author'), 'l3', 'step-1'); renderWorkspace({ - canvasViewportHubId: 'hub-workspace-l3-author', + canvasViewportHubId: h('hub-workspace-l3-author'), processContext: { processMap: mapWithStep() }, }); @@ -786,7 +890,7 @@ describe('CanvasWorkspace', () => { }); it('keeps the same focal step while switching author/read modes in L3', () => { - const hubId = 'hub-workspace-l3-mode-switch'; + const hubId = h('hub-workspace-l3-mode-switch'); useCanvasViewportStore.getState().setLevel(hubId, 'l3', 'step-2'); renderWorkspace({ @@ -860,14 +964,14 @@ describe('CanvasWorkspace', () => { }; renderWorkspace({ - processContext: { processHubId: 'hub-frame-2', processMap: mapWithStep() }, + processContext: { processHubId: h('hub-frame-2'), processMap: mapWithStep() }, findings: [wallFinding], onOpenWall: vi.fn(), }); expect(useSharedWallProps).toHaveBeenCalledWith( expect.objectContaining({ - hubId: 'hub-frame-2', + hubId: h('hub-frame-2'), }) ); }); diff --git a/packages/ui/src/components/Canvas/index.tsx b/packages/ui/src/components/Canvas/index.tsx index aa80b7c4d..30aa3eec2 100644 --- a/packages/ui/src/components/Canvas/index.tsx +++ b/packages/ui/src/components/Canvas/index.tsx @@ -8,8 +8,6 @@ import { chartColors } from '@variscout/charts'; import { coerceCanvasLens, coerceCanvasOverlays, - isCanvasLensValidAtLevel, - suggestCanvasLevelForLens, resolveEndpointToFactor, useCanvasViewportInput, useCanvasViewportShortcuts, @@ -17,7 +15,8 @@ import { useHasInvestigationContent, useChipDragAndDrop, useHypothesisDrawTool, - type ArrowEndpoint, + useCanvasHypothesisDrawing, + useCanvasHypothesisArrows, type CanvasInvestigationFocus, type CanvasInvestigationOverlayModel, type CanvasLensId, @@ -43,6 +42,7 @@ import { type CanvasViewportFit, type CanvasViewportSnapshot, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { type ProductionLineGlanceFilterStripProps, ProductionLineGlanceFilterStrip, @@ -50,16 +50,18 @@ import { import type { ProductionLineGlanceDashboardProps } from '../ProductionLineGlanceDashboard/types'; import { ProcessMapBase } from './internal/ProcessMapBase'; import { CanvasViewport } from './internal/CanvasViewport'; -import { LODSwitcher } from './internal/LODSwitcher'; import { MobileLevelPicker } from './internal/MobileLevelPicker'; +import { + CanvasLevelRouter, + type CanvasAuthoringMode, + type CanvasL3Archetype, +} from './internal/CanvasLevelRouter'; import { ChipRail, type ChipRailEntry } from '../ChipRail'; import { AutoStepCreatePrompt } from '../AutoStepCreatePrompt'; import { CanvasModeToggle } from '../CanvasModeToggle'; import { StructuralToolbar } from '../StructuralToolbar'; -import { CanvasLensPicker, LENS_LABEL_KEY } from './internal/CanvasLensPicker'; +import { CanvasLensPicker } from './internal/CanvasLensPicker'; import { useWallLocale } from '../InvestigationWall/hooks/useWallLocale'; -import { formatMessage, getMessage } from '@variscout/core/i18n'; -import type { MessageCatalog } from '@variscout/core'; import { CanvasOverlayPicker } from './internal/CanvasOverlayPicker'; import { HypothesisDrawToolButton } from './internal/HypothesisDrawToolButton'; import { @@ -70,10 +72,7 @@ import { CanvasStepCard } from './internal/CanvasStepCard'; import { CanvasStepOverlay, type CanvasOverlayAnchorRect } from './internal/CanvasStepOverlay'; import { CanvasWallOverlay } from './internal/CanvasWallOverlay'; import { WallShortcutButton } from './internal/WallShortcutButton'; -import { LocalMechanismView } from './internal/LocalMechanismView'; -import { SystemLevelView } from './internal/SystemLevelView'; -import { AuthorL3View } from './internal/AuthorL3View'; -import { NoFocalStepPrompt, sortedProcessSteps } from './internal/NoFocalStepPrompt'; +import { sortedProcessSteps } from './internal/NoFocalStepPrompt'; import { useWallIsMobile } from '../InvestigationWall'; import type { ContextLinkGroup, ContextLinkItem } from '../CrossSurface'; import type { LogActionPayload } from '../QuickAction'; @@ -91,16 +90,8 @@ import type { LogActionPayload } from '../QuickAction'; * component stays focused on the rendered canvas bands. */ export type ProductionLineGlanceOpsMode = 'spatial' | 'full'; -export type CanvasAuthoringMode = 'author' | 'read'; -export type CanvasL3Archetype = 'b0' | 'b1'; - -type ArrowSegment = { - id: string; - x1: number; - y1: number; - x2: number; - y2: number; -}; +// Re-exported from CanvasLevelRouter to avoid circular imports +export type { CanvasAuthoringMode, CanvasL3Archetype } from './internal/CanvasLevelRouter'; type CanvasQuestionOption = { id: string; text: string }; @@ -119,16 +110,11 @@ const DEFAULT_CANVAS_VIEWPORT: CanvasViewportSnapshot = { }; const CANVAS_VIEWPORT_IGNORED_TARGET = '[data-canvas-wall-overlay]'; -const CANVAS_LEVEL_LABEL_KEY: Record = { - l1: 'canvas.mobile.system', - l2: 'canvas.mobile.process', - l3: 'canvas.mobile.step', -}; const CANVAS_FIT_REQUEST_EVENT = 'variscout:canvas-fit-request'; const FIT_TO_CONTENT_MARGIN = 0.95; interface CanvasFitRequestDetail { - hubId: string; + hubId: ProcessHubId; level?: CanvasLevel; } @@ -168,20 +154,6 @@ function measureCanvasFit( }; } -function areArrowSegmentsEqual(left: ArrowSegment[], right: ArrowSegment[]) { - if (left.length !== right.length) return false; - return left.every((segment, index) => { - const next = right[index]; - return ( - segment.id === next.id && - segment.x1 === next.x1 && - segment.y1 === next.y1 && - segment.x2 === next.x2 && - segment.y2 === next.y2 - ); - }); -} - /** * Controlled inputs for the canonical Canvas implementation. * @@ -191,7 +163,7 @@ function areArrowSegmentsEqual(left: ArrowSegment[], right: ArrowSegment[]) { * or persistence directly. */ export interface CanvasProps { - hubId?: string; + hubId?: ProcessHubId; map: ProcessMap; availableColumns: string[]; onChange: (next: ProcessMap) => void; @@ -269,7 +241,7 @@ export interface CanvasProps { problemCpk?: number; eventsPerWeek?: number; activeColumns?: ReadonlyArray; - onOpenScout?: (hubId: string) => void; + onOpenScout?: (hubId: ProcessHubId) => void; onOpenWall?: () => void; onSelectWallHub?: (hubId: string) => void; onOpenColumnDetail?: (column: string, stepId: string) => void; @@ -383,13 +355,14 @@ export const Canvas: React.FC = ({ const [stepOverlayAnchor, setStepOverlayAnchor] = React.useState( null ); - const cardElements = React.useRef(new Map()); - const [arrowSegments, setArrowSegments] = React.useState([]); - const [arrowMeasureVersion, setArrowMeasureVersion] = React.useState(0); const drawTool = useHypothesisDrawTool({ active: activeCanvasTool === 'draw-hypothesis' && !disabled, }); - const resetDrawTool = drawTool.reset; + const stepMetricColumns = React.useMemo(() => { + const out: Record = {}; + for (const card of stepCards) out[card.stepId] = card.metricColumn; + return out; + }, [stepCards]); const pendingStepChip = pendingStepChipId ? chips.find(chip => chip.chipId === pendingStepChipId) : undefined; @@ -503,76 +476,16 @@ export const Canvas: React.FC = ({ useCanvasViewportStore.getState().setLevel(hubId, 'l3', firstStepId); }, [firstStepId, hubId, viewport.currentLevel, viewport.focalStepId]); - const stepMetricColumns = React.useMemo(() => { - const out: Record = {}; - for (const card of stepCards) out[card.stepId] = card.metricColumn; - return out; - }, [stepCards]); - - const registerCardElement = React.useCallback((stepId: string, element: HTMLElement | null) => { - if (element) cardElements.current.set(stepId, element); - else cardElements.current.delete(stepId); - }, []); - - React.useLayoutEffect(() => { - if ( - !resolvedOverlays.includes('hypotheses') || - !investigationOverlays || - !cardSurfaceRef.current - ) { - return; - } - - const refresh = () => setArrowMeasureVersion(version => version + 1); - const resizeObserver = - typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(refresh); - resizeObserver?.observe(cardSurfaceRef.current); - for (const element of cardElements.current.values()) resizeObserver?.observe(element); - window.addEventListener('resize', refresh); - - return () => { - resizeObserver?.disconnect(); - window.removeEventListener('resize', refresh); - }; - }, [investigationOverlays, resolvedLens, resolvedOverlays, stepCards]); - - React.useLayoutEffect(() => { - if ( - !resolvedOverlays.includes('hypotheses') || - !investigationOverlays || - !cardSurfaceRef.current - ) { - setArrowSegments(current => (current.length === 0 ? current : [])); - return; - } - const surfaceRect = cardSurfaceRef.current.getBoundingClientRect(); - const next = investigationOverlays.arrows.flatMap(arrow => { - const from = cardElements.current.get(arrow.fromStepId); - const to = cardElements.current.get(arrow.toStepId); - if (!from || !to) return []; - const fromRect = from.getBoundingClientRect(); - const toRect = to.getBoundingClientRect(); - return [ - { - id: arrow.id, - x1: fromRect.left + fromRect.width / 2 - surfaceRect.left, - y1: fromRect.top + fromRect.height / 2 - surfaceRect.top, - x2: toRect.left + toRect.width / 2 - surfaceRect.left, - y2: toRect.top + toRect.height / 2 - surfaceRect.top, - }, - ]; - }); - setArrowSegments(current => (areArrowSegmentsEqual(current, next) ? current : next)); - }, [ - arrowMeasureVersion, + const { arrowSegments, registerCardElement } = useCanvasHypothesisArrows({ + resolvedOverlays, investigationOverlays, + cardSurfaceRef, resolvedLens, - resolvedOverlays, stepCards, - viewport.pan.x, - viewport.pan.y, - viewport.zoom, - ]); + viewportPanX: viewport.pan.x, + viewportPanY: viewport.pan.y, + viewportZoom: viewport.zoom, + }); const handleOpenStepCard = React.useCallback((stepId: string, element: HTMLElement) => { const rect = element.getBoundingClientRect(); @@ -598,165 +511,14 @@ export const Canvas: React.FC = ({ return () => cancelAnimationFrame(frame); }, [handleCloseStepOverlay, viewport.currentLevel]); - const surfacePoint = React.useCallback( - (clientX: number, clientY: number): { x: number; y: number } => { - const rect = cardSurfaceRef.current?.getBoundingClientRect(); - return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }; - }, - [] - ); - - const endpointElementFromTarget = React.useCallback( - (target: EventTarget | null): Element | null => { - return target instanceof Element ? target.closest('[data-arrow-endpoint]') : null; - }, - [] - ); - - const parseEndpointElement = React.useCallback( - (element: Element | null): ArrowEndpoint | null => { - let node: Element | null = element; - while (node) { - const attr = node.getAttribute('data-arrow-endpoint'); - if (attr) { - const separator = attr.indexOf(':'); - if (separator < 0) return null; - const kind = attr.slice(0, separator); - const id = attr.slice(separator + 1); - if (kind === 'step') return { kind: 'step', id }; - if (kind === 'column') { - const directHostStepId = node.getAttribute('data-arrow-host-step-id'); - if (directHostStepId) return { kind: 'column', name: id, hostStepId: directHostStepId }; - let stepNode = node.parentElement; - while (stepNode) { - const hostStepId = stepNode.getAttribute('data-arrow-host-step-id'); - if (hostStepId) return { kind: 'column', name: id, hostStepId }; - const stepAttr = stepNode.getAttribute('data-arrow-endpoint'); - if (stepAttr?.startsWith('step:')) { - return { kind: 'column', name: id, hostStepId: stepAttr.slice(5) }; - } - stepNode = stepNode.parentElement; - } - } - } - node = node.parentElement; - } - return null; - }, - [] - ); - - const endpointFromPointerEvent = React.useCallback( - (event: React.PointerEvent): ArrowEndpoint | null => { - const targetElement = endpointElementFromTarget(event.target); - const fallbackElement = - typeof document === 'undefined' || typeof document.elementFromPoint !== 'function' - ? null - : document.elementFromPoint(event.clientX, event.clientY); - return parseEndpointElement(targetElement) ?? parseEndpointElement(fallbackElement); - }, - [endpointElementFromTarget, parseEndpointElement] - ); - - const endpointFromKeyboardEvent = React.useCallback( - ( - event: React.KeyboardEvent - ): { endpoint: ArrowEndpoint; at: { x: number; y: number } } | null => { - const element = endpointElementFromTarget(event.target); - const endpoint = parseEndpointElement(element); - if (!endpoint) return null; - const elementRect = element?.getBoundingClientRect(); - const surfaceRect = cardSurfaceRef.current?.getBoundingClientRect(); - if (elementRect && surfaceRect) { - return { - endpoint, - at: { - x: elementRect.left + elementRect.width / 2 - surfaceRect.left, - y: elementRect.top + elementRect.height / 2 - surfaceRect.top, - }, - }; - } - return { endpoint, at: { x: 0, y: 0 } }; - }, - [endpointElementFromTarget, parseEndpointElement] - ); - - const handleDrawPointerDown = React.useCallback( - (event: React.PointerEvent): void => { - if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; - const endpoint = endpointFromPointerEvent(event); - if (!endpoint) return; - const sourceElement = endpointElementFromTarget(event.target); - const sourceRect = sourceElement?.getBoundingClientRect(); - const surfaceRect = cardSurfaceRef.current?.getBoundingClientRect(); - const anchor = - sourceRect && surfaceRect - ? { - x: sourceRect.left + sourceRect.width / 2 - surfaceRect.left, - y: sourceRect.top + sourceRect.height / 2 - surfaceRect.top, - } - : surfacePoint(event.clientX, event.clientY); - event.preventDefault(); - drawTool.onPointerDown(endpoint, anchor); - }, - [ - activeCanvasTool, - disabled, - drawTool, - endpointElementFromTarget, - endpointFromPointerEvent, - surfacePoint, - ] - ); - - const handleDrawPointerMove = React.useCallback( - (event: React.PointerEvent): void => { - if (drawTool.state.phase !== 'drawing') return; - drawTool.onPointerMove( - surfacePoint(event.clientX, event.clientY), - endpointFromPointerEvent(event) - ); - }, - [drawTool, endpointFromPointerEvent, surfacePoint] - ); - - const handleDrawPointerUp = React.useCallback( - (event: React.PointerEvent): void => { - if (drawTool.state.phase !== 'drawing') return; - drawTool.onPointerUp( - endpointFromPointerEvent(event), - surfacePoint(event.clientX, event.clientY) - ); - }, - [drawTool, endpointFromPointerEvent, surfacePoint] - ); - - const handleDrawKeyDown = React.useCallback( - (event: React.KeyboardEvent): void => { - if (activeCanvasTool !== 'draw-hypothesis' || disabled) return; - if (event.key === 'Escape') { - drawTool.cancel(); - onCanvasToolChange?.('select'); - return; - } - if (event.key !== 'Enter' && event.key !== ' ') return; - const resolved = endpointFromKeyboardEvent(event); - if (!resolved) return; - event.preventDefault(); - if (drawTool.state.phase === 'drawing') { - drawTool.onPointerUp(resolved.endpoint, resolved.at); - } else { - drawTool.onPointerDown(resolved.endpoint, resolved.at); - } - }, - [activeCanvasTool, disabled, drawTool, endpointFromKeyboardEvent, onCanvasToolChange] - ); - - const endpointLabel = React.useCallback( - (endpoint: ArrowEndpoint): string => - endpoint.kind === 'column' ? endpoint.name : (stepMetricColumns[endpoint.id] ?? endpoint.id), - [stepMetricColumns] - ); + const { handlers: drawHandlers, endpointLabel } = useCanvasHypothesisDrawing({ + activeCanvasTool, + disabled, + drawTool, + cardSurfaceRef, + onCanvasToolChange, + stepMetricColumns, + }); const handleHypothesisSave = React.useCallback( (payload: HypothesisDraftPayload): void => { @@ -773,10 +535,6 @@ export const Canvas: React.FC = ({ [drawTool, onAddCausalLink, onCanvasToolChange, stepMetricColumns] ); - React.useEffect(() => { - if (activeCanvasTool !== 'draw-hypothesis') resetDrawTool(); - }, [activeCanvasTool, resetDrawTool]); - const stepCardGrid = stepCards.length > 0 ? (
@@ -857,11 +615,11 @@ export const Canvas: React.FC = ({ activeCanvasTool === 'draw-hypothesis' && !disabled ? 'cursor-crosshair' : '', ].join(' ')} data-testid="canvas-card-surface" - onPointerDown={handleDrawPointerDown} - onPointerMove={handleDrawPointerMove} - onPointerUp={handleDrawPointerUp} + onPointerDown={drawHandlers.onPointerDown} + onPointerMove={drawHandlers.onPointerMove} + onPointerUp={drawHandlers.onPointerUp} onPointerCancel={() => drawTool.onPointerCancel()} - onKeyDown={handleDrawKeyDown} + onKeyDown={drawHandlers.onKeyDown} style={{ touchAction: activeCanvasTool === 'draw-hypothesis' ? 'none' : undefined }} > {resolvedOverlays.includes('hypotheses') && arrowSegments.length > 0 ? ( @@ -973,50 +731,36 @@ export const Canvas: React.FC = ({
); - const l1Content = ( -
- -
- ); - const authorL3Content = viewport.focalStepId ? ( - - ) : ( - - ); - const readL3Content = viewport.focalStepId ? ( - = ({ onCharter={onCharter} onSustainment={onSustainment} onHandoff={onHandoff} + resolvedL3Archetype={resolvedL3Archetype} + authoringMode={authoringMode} + disabled={disabled} + onModeChange={onModeChange} /> - ) : ( - - ); - const l3ContentBody = resolvedL3Archetype === 'b1' ? authorL3Content : readL3Content; - const l3Content = ( -
- {onModeChange ? ( -
- -
- ) : null} - {l3ContentBody} -
- ); - const lensValidAtCurrentLevel = isCanvasLensValidAtLevel(rawLens, viewport.currentLevel); - const suggestedLevel = suggestCanvasLevelForLens(rawLens, viewport.currentLevel); - const invalidLensLevelContent = ( -
- {formatMessage(locale, 'canvas.lensPicker.invalidAtLevel', { - lens: getMessage(locale, LENS_LABEL_KEY[rawLens]), - currentLevel: getMessage(locale, CANVAS_LEVEL_LABEL_KEY[viewport.currentLevel]), - suggestedLevel: getMessage(locale, CANVAS_LEVEL_LABEL_KEY[suggestedLevel]), - })} -
- ); - const levelContent = lensValidAtCurrentLevel ? ( - - ) : ( - invalidLensLevelContent ); const desktopLevelContent = (
= { + l1: 'canvas.mobile.system', + l2: 'canvas.mobile.process', + l3: 'canvas.mobile.step', +}; + +export interface CanvasLevelRouterProps { + // Shared identity + hubId: ProcessHubId; + map: ProcessMap; + // Current viewport state + currentLevel: CanvasLevel; + focalStepId: string | undefined; + rawLens: CanvasLensId; + resolvedLens: CanvasLensId; + locale: Locale; + // L2 content comes from the parent (Canvas manages this surface) + l2Content: React.ReactNode; + // L1 props + rows: readonly DataRow[]; + stepCards: readonly CanvasStepCardModel[]; + systemQuestions: ReadonlyArray; + hypotheses: ReadonlyArray; + findings: ReadonlyArray; + usl?: number; + lsl?: number; + target?: number; + cpkTarget?: number; + onOpenScout?: (hubId: ProcessHubId) => void; + // L3 author props + chips: ChipRailEntry[]; + canPlaceChips: boolean; + onPlaceChip?: (chipId: string, stepId: string) => void; + onKeyboardChipPickUp?: (chipId: string) => void; + onKeyboardChipDrop?: (stepId: string) => void; + // L3 read props + columnTypes: ColumnTypeMap; + problemCpk?: number; + eventsPerWeek?: number; + availableColumns: string[]; + activeColumns?: ReadonlyArray; + onOpenWall?: () => void; + onSelectWallHub?: (hubId: string) => void; + onOpenInvestigationFocus?: (focus: CanvasInvestigationFocus) => void; + onOpenColumnDetail?: (column: string, stepId: string) => void; + onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; + onFocusedInvestigation?: (stepId: string) => void; + onCharter?: (stepId: string) => void; + onSustainment?: (stepId: string) => void; + onHandoff?: (stepId: string) => void; + // L3 mode toggle + resolvedL3Archetype: CanvasL3Archetype; + authoringMode: CanvasAuthoringMode; + disabled?: boolean; + onModeChange?: (next: CanvasAuthoringMode) => void; +} + +/** + * Routes canvas content across L1 / L2 / L3 levels and handles the lens-level + * validity gate. Pure presentation — no store reads, no hooks besides what the + * sub-components own internally. + */ +export function CanvasLevelRouter({ + hubId, + map, + currentLevel, + focalStepId, + rawLens, + resolvedLens, + locale, + l2Content, + rows, + stepCards, + systemQuestions, + hypotheses, + findings, + usl, + lsl, + target, + cpkTarget, + onOpenScout, + chips, + canPlaceChips, + onPlaceChip, + onKeyboardChipPickUp, + onKeyboardChipDrop, + columnTypes, + problemCpk, + eventsPerWeek, + availableColumns, + activeColumns, + onOpenWall, + onSelectWallHub, + onOpenInvestigationFocus, + onOpenColumnDetail, + onLogQuickAction, + onFocusedInvestigation, + onCharter, + onSustainment, + onHandoff, + resolvedL3Archetype, + authoringMode, + disabled, + onModeChange, +}: CanvasLevelRouterProps): React.JSX.Element { + const lensValidAtCurrentLevel = isCanvasLensValidAtLevel(rawLens, currentLevel); + const suggestedLevel = suggestCanvasLevelForLens(rawLens, currentLevel); + + if (!lensValidAtCurrentLevel) { + return ( +
+ {formatMessage(locale, 'canvas.lensPicker.invalidAtLevel', { + lens: getMessage(locale, LENS_LABEL_KEY[rawLens]), + currentLevel: getMessage(locale, CANVAS_LEVEL_LABEL_KEY[currentLevel]), + suggestedLevel: getMessage(locale, CANVAS_LEVEL_LABEL_KEY[suggestedLevel]), + })} +
+ ); + } + + const l1Content = ( +
+ +
+ ); + + const authorL3Content = focalStepId ? ( + + ) : ( + + ); + + const readL3Content = focalStepId ? ( + + ) : ( + + ); + + const l3ContentBody = resolvedL3Archetype === 'b1' ? authorL3Content : readL3Content; + + const l3Content = ( +
+ {onModeChange ? ( +
+ +
+ ) : null} + {l3ContentBody} +
+ ); + + return ; +} diff --git a/packages/ui/src/components/Canvas/internal/CanvasWallOverlay.tsx b/packages/ui/src/components/Canvas/internal/CanvasWallOverlay.tsx index 1fe2948dc..bca3fceaa 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasWallOverlay.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasWallOverlay.tsx @@ -6,11 +6,11 @@ import { type CanvasOverlayId, type CanvasToolId, } from '@variscout/hooks'; -import type { Finding, ProcessMap } from '@variscout/core'; +import type { Finding, ProcessMap, ProcessHubId } from '@variscout/core'; import { WallCanvas, useWallIsMobile } from '../../InvestigationWall'; export interface CanvasWallOverlayProps { - hubId: string; + hubId: ProcessHubId; activeOverlays: CanvasOverlayId[]; activeCanvasTool: CanvasToolId; findings: Finding[]; diff --git a/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx b/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx index ec265db91..66f8d8b4c 100644 --- a/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx +++ b/packages/ui/src/components/Canvas/internal/LocalMechanismView.tsx @@ -13,6 +13,7 @@ import type { ColumnTypeMap } from '@variscout/core/findings'; import { EvidenceMapBase } from '@variscout/charts'; import { useEvidenceMapData } from '@variscout/hooks'; import { useInvestigationStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { WallCanvas } from '../../InvestigationWall/WallCanvas'; import { MiniBoxplot } from '../../InvestigationWall/MiniBoxplot'; import { MiniIChart } from '../../InvestigationWall/MiniIChart'; @@ -20,7 +21,7 @@ import { useWallLocale } from '../../InvestigationWall/hooks/useWallLocale'; import { LogActionModal, type LogActionPayload } from '../../QuickAction'; export interface LocalMechanismViewProps { - hubId: string; + hubId: ProcessHubId; focalStepId: string; map: ProcessMap; rows?: ReadonlyArray; diff --git a/packages/ui/src/components/Canvas/internal/MobileLevelPicker.tsx b/packages/ui/src/components/Canvas/internal/MobileLevelPicker.tsx index d92bb773c..2af3cde00 100644 --- a/packages/ui/src/components/Canvas/internal/MobileLevelPicker.tsx +++ b/packages/ui/src/components/Canvas/internal/MobileLevelPicker.tsx @@ -2,7 +2,8 @@ import React from 'react'; import type { MessageCatalog } from '@variscout/core'; import { getMessage } from '@variscout/core/i18n'; import type { CanvasLevel } from '@variscout/core/canvas'; -import { useCanvasViewportStore, type ProcessHubId } from '@variscout/stores'; +import { useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useWallLocale } from '../../InvestigationWall/hooks/useWallLocale'; export interface MobileLevelPickerProps { diff --git a/packages/ui/src/components/Canvas/internal/NoFocalStepPrompt.tsx b/packages/ui/src/components/Canvas/internal/NoFocalStepPrompt.tsx index ad84db2fa..b0e9c4d6c 100644 --- a/packages/ui/src/components/Canvas/internal/NoFocalStepPrompt.tsx +++ b/packages/ui/src/components/Canvas/internal/NoFocalStepPrompt.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { formatMessage, getMessage } from '@variscout/core/i18n'; import type { ProcessMap } from '@variscout/core/frame'; import { useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useWallLocale } from '../../InvestigationWall/hooks/useWallLocale'; export interface NoFocalStepPromptProps { - hubId: string; + hubId: ProcessHubId; map: ProcessMap; } diff --git a/packages/ui/src/components/Canvas/internal/SystemLevelView.tsx b/packages/ui/src/components/Canvas/internal/SystemLevelView.tsx index 2b912fd32..9d672396c 100644 --- a/packages/ui/src/components/Canvas/internal/SystemLevelView.tsx +++ b/packages/ui/src/components/Canvas/internal/SystemLevelView.tsx @@ -6,6 +6,7 @@ import { type Hypothesis, type Question, type SpecLimits, + type ProcessHubId, } from '@variscout/core'; import { formatMessage, formatStatistic, getMessage } from '@variscout/core/i18n'; import type { Locale } from '@variscout/core'; @@ -16,7 +17,7 @@ import { InboxDigest, type InboxDigestPrompt } from '../../Inbox'; import { useWallLocale } from '../../InvestigationWall/hooks/useWallLocale'; export interface SystemLevelViewProps { - hubId: string; + hubId: ProcessHubId; map: ProcessMap; rows: readonly DataRow[]; stepCards?: readonly CanvasStepCardModel[]; @@ -41,7 +42,7 @@ export interface SystemLevelViewProps { * @deprecated Thread `measureSpecs` from the canonical store instead. */ specLimitsOverride?: SpecLimits; - onOpenScout?: (hubId: string) => void; + onOpenScout?: (hubId: ProcessHubId) => void; } const SPARK_WIDTH = 220; diff --git a/packages/ui/src/components/Canvas/internal/__tests__/CanvasLevelRouter.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/CanvasLevelRouter.test.tsx new file mode 100644 index 000000000..8e87c6fd5 --- /dev/null +++ b/packages/ui/src/components/Canvas/internal/__tests__/CanvasLevelRouter.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProcessMap } from '@variscout/core/frame'; +import type { ProcessHubId } from '@variscout/core/processHub'; + +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + +// vi.mock hoisted above component imports — required by project testing rules. +vi.mock('@variscout/hooks', async () => { + const actual = await vi.importActual('@variscout/hooks'); + return { + ...actual, + isCanvasLensValidAtLevel: vi.fn(() => true), + suggestCanvasLevelForLens: vi.fn(() => 'l2' as const), + }; +}); + +vi.mock('../SystemLevelView', () => ({ + SystemLevelView: () =>
, +})); + +vi.mock('../AuthorL3View', () => ({ + AuthorL3View: () =>
, +})); + +vi.mock('../LocalMechanismView', () => ({ + LocalMechanismView: () =>
, +})); + +vi.mock('../NoFocalStepPrompt', () => ({ + NoFocalStepPrompt: () =>
, +})); + +vi.mock('../LODSwitcher', () => ({ + LODSwitcher: ({ + currentLevel, + l1, + l2, + l3, + }: { + currentLevel: string; + l1: React.ReactNode; + l2: React.ReactNode; + l3: React.ReactNode; + }) => ( +
+
{l1}
+
{l2}
+
{l3}
+
+ ), +})); + +vi.mock('../../../CanvasModeToggle', () => ({ + CanvasModeToggle: ({ + mode, + onChange, + disabled, + }: { + mode: string; + onChange: (next: string) => void; + disabled?: boolean; + }) => ( + + ), +})); + +import { isCanvasLensValidAtLevel, suggestCanvasLevelForLens } from '@variscout/hooks'; +import { CanvasLevelRouter } from '../CanvasLevelRouter'; + +const map: ProcessMap = { + version: 1, + ctsColumn: 'Fill Weight', + nodes: [ + { id: 'mix', name: 'Mix', order: 0 }, + { id: 'fill', name: 'Fill', order: 1 }, + ], + tributaries: [], + createdAt: '2026-05-13T00:00:00.000Z', + updatedAt: '2026-05-13T00:00:00.000Z', +}; + +const baseProps = { + hubId: h('hub-1'), + map, + currentLevel: 'l2' as const, + focalStepId: 'fill', + rawLens: 'default' as const, + resolvedLens: 'default' as const, + locale: 'en' as const, + l2Content:
L2 content
, + rows: [], + stepCards: [], + systemQuestions: [], + hypotheses: [], + findings: [], + chips: [], + canPlaceChips: false, + columnTypes: {}, + availableColumns: [], + resolvedL3Archetype: 'b0' as const, + authoringMode: 'read' as const, +}; + +describe('CanvasLevelRouter', () => { + it('renders LODSwitcher when lens is valid at current level', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render(); + + expect(screen.getByTestId('mock-lod-switcher')).toBeInTheDocument(); + expect(screen.queryByTestId('canvas-lens-level-empty-state')).not.toBeInTheDocument(); + }); + + it('renders lens-level empty state when lens is invalid at current level', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(false); + vi.mocked(suggestCanvasLevelForLens).mockReturnValue('l2'); + + render(); + + expect(screen.getByTestId('canvas-lens-level-empty-state')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-lod-switcher')).not.toBeInTheDocument(); + }); + + it('passes l1 slot with SystemLevelView to LODSwitcher', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render(); + + expect(screen.getByTestId('lod-l1')).toBeInTheDocument(); + expect(screen.getByTestId('mock-system-level-view')).toBeInTheDocument(); + }); + + it('passes l2 slot content from l2Content prop to LODSwitcher', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render(); + + expect(screen.getByTestId('l2-content')).toBeInTheDocument(); + }); + + it('renders LocalMechanismView in l3 slot when archetype is b0 and focalStepId is set', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render( + + ); + + expect(screen.getByTestId('mock-local-mechanism-view')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-author-l3-view')).not.toBeInTheDocument(); + }); + + it('renders AuthorL3View in l3 slot when archetype is b1 and focalStepId is set', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render( + + ); + + expect(screen.getByTestId('mock-author-l3-view')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-local-mechanism-view')).not.toBeInTheDocument(); + }); + + it('renders NoFocalStepPrompt in l3 slot when focalStepId is undefined', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render( + + ); + + expect(screen.getByTestId('mock-no-focal-step-prompt')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-local-mechanism-view')).not.toBeInTheDocument(); + }); + + it('renders mode toggle in l3 slot when onModeChange is provided', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render( + + ); + + expect(screen.getByTestId('mock-canvas-mode-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('mock-canvas-mode-toggle')).toHaveAttribute('data-mode', 'read'); + }); + + it('does not render mode toggle in l3 slot when onModeChange is absent', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render( + + ); + + expect(screen.queryByTestId('mock-canvas-mode-toggle')).not.toBeInTheDocument(); + }); + + it('passes current level to LODSwitcher', () => { + vi.mocked(isCanvasLensValidAtLevel).mockReturnValue(true); + + render(); + + expect(screen.getByTestId('mock-lod-switcher')).toHaveAttribute('data-current-level', 'l3'); + }); +}); diff --git a/packages/ui/src/components/Canvas/internal/__tests__/CanvasWallOverlay.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/CanvasWallOverlay.test.tsx index 953b407fa..972cbe654 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/CanvasWallOverlay.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/CanvasWallOverlay.test.tsx @@ -8,9 +8,13 @@ import { useCanvasViewportStore, useInvestigationStore, } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { useWallIsMobile } from '../../../InvestigationWall'; import { CanvasWallOverlay } from '../CanvasWallOverlay'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + interface D3ZoomElement extends HTMLDivElement { __zoom?: { k: number; x: number; y: number }; __on?: Array<{ type: string; name: string; value: (event: Event) => void }>; @@ -42,7 +46,7 @@ vi.mock('../../../InvestigationWall', () => ({
role button target
- -
@@ -67,7 +71,7 @@ vi.mock('../../../InvestigationWall', () => ({ const useWallIsMobileMock = vi.mocked(useWallIsMobile); const sampleHub: Hypothesis = { - id: 'hub-1', + id: h('hub-1'), name: 'Thermal drift', synthesis: '', status: 'proposed', @@ -91,8 +95,8 @@ const sampleFinding: Finding = { deletedAt: null, investigationId: 'inv-test-001', }; -const HUB_ID = 'hub-overlay-test'; -const OTHER_HUB_ID = 'hub-overlay-other'; +const HUB_ID = h('hub-overlay-test'); +const OTHER_HUB_ID = h('hub-overlay-other'); function renderOverlay(overrides: Partial> = {}) { return render( diff --git a/packages/ui/src/components/Canvas/internal/__tests__/LocalMechanismView.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/LocalMechanismView.test.tsx index 0570c82d6..cb6df5b36 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/LocalMechanismView.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/LocalMechanismView.test.tsx @@ -4,8 +4,12 @@ import { act } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Finding, Hypothesis, ProcessMap, Question } from '@variscout/core'; import { getInvestigationInitialState, useInvestigationStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { LocalMechanismView } from '../LocalMechanismView'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + vi.mock('@variscout/charts', async () => { const actual = await vi.importActual('@variscout/charts'); return { @@ -50,7 +54,7 @@ vi.mock('../../../InvestigationWall/WallCanvas', () => ({ data-rows-count={props.rows?.length ?? 0} data-outcome-column={props.outcomeColumn ?? ''} > -
@@ -110,7 +114,7 @@ function question(overrides: Partial = {}): Question { function hub(overrides: Partial = {}): Hypothesis { return { - id: overrides.id ?? 'hub-1', + id: overrides.id ?? h('hub-1'), name: overrides.name ?? 'Machine setup drift', synthesis: overrides.synthesis ?? '', questionIds: overrides.questionIds ?? [], @@ -127,7 +131,7 @@ function hub(overrides: Partial = {}): Hypothesis { function renderView(overrides: Partial> = {}) { return render( id as ProcessHubId; + describe('MobileLevelPicker', () => { beforeEach(() => { useCanvasViewportStore.setState(getCanvasViewportInitialState()); }); it('renders System, Process, and Step controls with the active level marked', () => { - render(); + render(); expect(screen.getByRole('button', { name: 'System' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: 'Process' })).toHaveAttribute('aria-pressed', 'true'); @@ -17,17 +21,17 @@ describe('MobileLevelPicker', () => { }); it('writes level changes through fitToContent', () => { - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'System' })); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l1', zoom: 0.2, pan: { x: 0, y: 0 }, }); fireEvent.click(screen.getByRole('button', { name: 'Process' })); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l2', zoom: 1, pan: { x: 0, y: 0 }, @@ -35,28 +39,30 @@ describe('MobileLevelPicker', () => { }); it('navigates directly to l3 without focal step so canvas renders step-list (spec §7)', () => { - render(); + render(); const stepButton = screen.getByRole('button', { name: 'Step' }); expect(stepButton).toBeEnabled(); expect(() => fireEvent.click(stepButton)).not.toThrow(); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l3', zoom: 2.5, }); // No focalStepId — canvas NoFocalStepPrompt step-list will render at l3. - expect(useCanvasViewportStore.getState().getViewport('hub-mobile').focalStepId).toBeUndefined(); + expect( + useCanvasViewportStore.getState().getViewport(h('hub-mobile')).focalStepId + ).toBeUndefined(); }); it('enables Step with a focal step and writes l3 through fitToContent', () => { - useCanvasViewportStore.getState().setLevel('hub-mobile', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-mobile'), 'l3', 'step-1'); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Step' })); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', zoom: 2.5, @@ -65,20 +71,20 @@ describe('MobileLevelPicker', () => { }); it('remembers the last focal step for Step re-entry while mounted', () => { - useCanvasViewportStore.getState().setLevel('hub-mobile', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-mobile'), 'l3', 'step-1'); const { rerender } = render( - + ); - rerender(); + rerender(); const stepButton = screen.getByRole('button', { name: 'Step' }); expect(stepButton).toBeEnabled(); fireEvent.click(stepButton); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', zoom: 2.5, @@ -87,33 +93,35 @@ describe('MobileLevelPicker', () => { }); it('does not reuse a remembered focal step after hub changes', () => { - useCanvasViewportStore.getState().setLevel('hub-a', 'l3', 'step-a'); + useCanvasViewportStore.getState().setLevel(h('hub-a'), 'l3', 'step-a'); const { rerender } = render( ); - rerender(); + rerender( + + ); fireEvent.click(screen.getByRole('button', { name: 'Step' })); - expect(useCanvasViewportStore.getState().getViewport('hub-b')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-b'))).toMatchObject({ currentLevel: 'l3', zoom: 2.5, }); - expect(useCanvasViewportStore.getState().getViewport('hub-b').focalStepId).toBeUndefined(); + expect(useCanvasViewportStore.getState().getViewport(h('hub-b')).focalStepId).toBeUndefined(); }); it('ignores a remembered focal step that is no longer in availableStepIds', () => { - useCanvasViewportStore.getState().setLevel('hub-mobile', 'l3', 'step-1'); + useCanvasViewportStore.getState().setLevel(h('hub-mobile'), 'l3', 'step-1'); const { rerender } = render( { ); rerender( - + ); fireEvent.click(screen.getByRole('button', { name: 'Step' })); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-mobile'))).toMatchObject({ currentLevel: 'l3', zoom: 2.5, }); - expect(useCanvasViewportStore.getState().getViewport('hub-mobile').focalStepId).toBeUndefined(); + expect( + useCanvasViewportStore.getState().getViewport(h('hub-mobile')).focalStepId + ).toBeUndefined(); }); }); diff --git a/packages/ui/src/components/Canvas/internal/__tests__/NoFocalStepPrompt.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/NoFocalStepPrompt.test.tsx index 4f3aa64e5..4a7857424 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/NoFocalStepPrompt.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/NoFocalStepPrompt.test.tsx @@ -2,8 +2,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import type { ProcessMap } from '@variscout/core/frame'; import { getCanvasViewportInitialState, useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; import { NoFocalStepPrompt } from '../NoFocalStepPrompt'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + const map: ProcessMap = { version: 1, nodes: [ @@ -21,7 +25,7 @@ describe('NoFocalStepPrompt', () => { }); it('lists steps in process order and sets L3 focal step from a button click', () => { - render(); + render(); const prompt = screen.getByTestId('no-focal-step-prompt'); expect(prompt).toHaveAccessibleName(/choose a process step/i); @@ -32,7 +36,7 @@ describe('NoFocalStepPrompt', () => { fireEvent.click(screen.getByRole('button', { name: /open mix local mechanism/i })); - expect(useCanvasViewportStore.getState().getViewport('hub-prompt')).toMatchObject({ + expect(useCanvasViewportStore.getState().getViewport(h('hub-prompt'))).toMatchObject({ currentLevel: 'l3', focalStepId: 'step-1', }); diff --git a/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx index 578bfc406..a7637fa04 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/SystemLevelView.test.tsx @@ -1,9 +1,13 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { ProcessMap } from '@variscout/core/frame'; +import type { ProcessHubId } from '@variscout/core/processHub'; import type { DataRow, Finding, Hypothesis, Question } from '@variscout/core'; import { SystemLevelView } from '../SystemLevelView'; +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; + const map: ProcessMap = { version: 1, ctsColumn: 'Fill Weight', @@ -90,7 +94,7 @@ describe('SystemLevelView', () => { it('renders the hub outcome panel from the hub outcome series without response-path CTAs', () => { render( { /> ); - expect(screen.getByText('hub-fill')).toBeInTheDocument(); + expect(screen.getByText(h('hub-fill'))).toBeInTheDocument(); expect(screen.getByText('Fill Weight')).toBeInTheDocument(); expect(screen.getByTestId('outcome-distribution')).toHaveTextContent('n=5'); expect(screen.getByTestId('drift-indicator')).toBeInTheDocument(); @@ -129,7 +133,7 @@ describe('SystemLevelView', () => { const onOpenScout = vi.fn(); render( { // Wide spec → all 5 rows in-spec → outOfSpec = 0 → inbox has no prompts render( { // production. The test asserts the behavior is deterministic (no silent NaN). const { rerender } = render( { // — simulates a caller that forgets to key by the outcome column rerender( = ({ const { onDragEnd } = useWallDragDrop({ onDrop: onComposeGate }); const isMobile = useWallIsMobile(); useCanvasViewportInput({ - hubId: hubId ?? '__wall-canvas-unbound__', + hubId: hubId ?? null, ref: svgRef, disabled: mode !== 'destination' || !hubId || isMobile || filteredHubs.length === 0, filter: shouldHandleWallPanInput, diff --git a/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.test.tsx b/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.test.tsx index bfa0c56bf..1e30e6fea 100644 --- a/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.test.tsx +++ b/packages/ui/src/components/InvestigationWall/__tests__/WallCanvas.test.tsx @@ -3,6 +3,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { WallCanvas } from '../WallCanvas'; import type { Hypothesis, ProcessMap, Question, Finding } from '@variscout/core'; import { getCanvasViewportInitialState, useCanvasViewportStore } from '@variscout/stores'; +import type { ProcessHubId } from '@variscout/core/processHub'; + +// Cast helper: acceptable inside test files per project convention +const h = (id: string) => id as ProcessHubId; interface D3ZoomSvgElement extends SVGSVGElement { __zoom?: { k: number; x: number; y: number }; @@ -623,7 +627,7 @@ describe('WallCanvas', () => { }); it('binds d3 zoom input to the destination SVG when hubId is provided', () => { - const hubId = 'wall-destination-hub'; + const hubId = h('wall-destination-hub'); const { container } = render( { }); it('keeps descendant role-button wheel from updating the hub viewport while background wheel still works', () => { - const hubId = 'wall-role-button-guard'; + const hubId = h('wall-role-button-guard'); const { container } = render( { it('does not bind d3 zoom input for overlay SVG even when hubId is provided defensively', () => { const { container } = render( { restoreMatchMedia = installMobileMatchMedia(); const { container } = render(