diff --git a/apps/azure/e2e/helpers.ts b/apps/azure/e2e/helpers.ts index 0f336f5fc..695bee379 100644 --- a/apps/azure/e2e/helpers.ts +++ b/apps/azure/e2e/helpers.ts @@ -1,18 +1,47 @@ import { expect, type Page } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; +import { fileURLToPath } from 'url'; + +// ESM-compatible __dirname equivalent +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Complete the ColumnMapping step after loading data. * * After a sample is loaded (or data pasted/uploaded), the app shows - * the "Map Your Data" ColumnMapping screen where the user confirms - * column selections. Sample datasets auto-detect the outcome column, - * so the "Start Analysis" button is already enabled. + * the "Map Your Data" ColumnMapping screen (Stage 3). The refactored + * ColumnMapping uses OutcomeCandidateRow for multi-select. Sample + * datasets auto-detect the outcome column, so the initialOutcome + * prop pre-selects a row — the "Start Analysis" button becomes enabled + * when at least one candidate row is selected. + * + * @param outcomeName - Optional column name to explicitly select as the + * outcome. If omitted, relies on the pre-selected initialOutcome. */ -export async function confirmColumnMapping(page: Page) { +export async function confirmColumnMapping(page: Page, outcomeName?: string) { await expect(page.locator('text=Map Your Data')).toBeVisible({ timeout: 5000 }); - await page.locator('button:has-text("Start Analysis")').click(); + + // If a specific outcome was requested, select it via the checkbox input with + // the matching aria-label on the OutcomeCandidateRow. + if (outcomeName) { + const outcomeCheckbox = page.locator( + `[data-testid="outcome-candidate-list"] input[type="checkbox"][aria-label="${outcomeName}"]` + ); + const isVisible = await outcomeCheckbox.isVisible().catch(() => false); + if (isVisible) { + const isChecked = await outcomeCheckbox.isChecked().catch(() => false); + if (!isChecked) { + await outcomeCheckbox.click(); + } + } + } + + // Click Start Analysis / Apply Changes (whichever is present) + await page + .locator('button:has-text("Start Analysis"), button:has-text("Apply Changes")') + .first() + .click(); } /** @@ -46,6 +75,44 @@ export async function loadPerformanceSample(page: Page) { await expect(page.locator('[data-testid="chart-ichart"]')).toBeVisible({ timeout: 15000 }); } +// ── Mode B helpers ──────────────────────────────────────────────────────────── + +/** CSV data with a clear numeric outcome (weight_g) + two categoricals */ +export const MODE_B_CSV = [ + 'weight_g,product,shift,batch_id', + '4.5,A,morning,B1', + '4.4,A,morning,B1', + '4.6,B,evening,B2', + '4.5,B,evening,B2', + '4.4,A,morning,B3', + '4.5,A,morning,B3', + '4.6,B,evening,B4', + '4.3,A,morning,B4', + '4.7,B,evening,B5', + '4.5,A,morning,B5', +].join('\n'); + +/** + * Paste CSV data into the PasteScreen (data-testid="paste-textarea") and + * click Start Analysis. + */ +export async function pasteDataAndAnalyze(page: Page, csv: string = MODE_B_CSV): Promise { + await expect(page.getByTestId('paste-textarea')).toBeVisible({ timeout: 8000 }); + await page.getByTestId('paste-textarea').fill(csv); + await page.getByTestId('paste-start-analysis').click(); +} + +/** + * Complete Stage 1 (HubGoalForm) in HubCreationFlow. + * Waits for the hub-creation-stage1 container, fills the goal narrative, + * and clicks Continue. + */ +export async function completeStage1(page: Page, narrative: string): Promise { + await expect(page.getByTestId('hub-creation-stage1')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill(narrative); + await page.getByRole('button', { name: /Continue/i }).click(); +} + /** * Mock the AI endpoint with a fixture response. * Intercepts requests to the AI endpoint and returns fixture data. diff --git a/apps/azure/e2e/modeB-framing.spec.ts b/apps/azure/e2e/modeB-framing.spec.ts new file mode 100644 index 000000000..38813c34a --- /dev/null +++ b/apps/azure/e2e/modeB-framing.spec.ts @@ -0,0 +1,279 @@ +// apps/azure/e2e/modeB-framing.spec.ts +// +// Azure Mode B framing: Paste Data → HubCreationFlow (Stage 1 + Stage 3) → analysis canvas. +// +// Test groups: +// 1. Full Mode B via Editor paste — from "Paste Data" button through HubGoalForm and +// ColumnMapping to the analysis canvas (I-chart visible confirms outcome is set). +// 2. GoalBanner edit roundtrip via ProcessHubView — navigates to portfolio, opens a +// hub card, edits GoalBanner inline, saves. Skips gracefully when portfolio +// is not accessible from a clean context (no saved projects). +// 3. "New Hub" from ProjectDashboard sidebar — loads a sample, navigates to Overview +// tab, clicks action-new-hub, runs Mode B paste flow again. +// Skips gracefully when sample picker is unavailable. +// 4. Portfolio ProcessHubView "Add framing" CTA — navigates back from editor to +// portfolio, clicks "New Hub", verifies hub-framing-prompt. +// Skips gracefully when portfolio is not accessible. +import { test, expect } from '@playwright/test'; +import { confirmColumnMapping, pasteDataAndAnalyze, completeStage1, MODE_B_CSV } from './helpers'; + +const GOAL_NARRATIVE = + 'We mold syringe barrels for medical customers. Weight in grams matters most.'; +const EDITED_GOAL = 'We produce precision medical components. Weight accuracy is critical.'; + +// --------------------------------------------------------------------------- +// Helper: wait for the Azure app to finish loading. +// On localhost auth auto-resolves (isLocalDev → LOCAL_USER). The first stable +// anchor is the Editor empty-state heading or the analysis tab when a project +// is already loaded. +// --------------------------------------------------------------------------- +async function waitForApp(page: import('@playwright/test').Page) { + await page.goto('/'); + await expect( + page + .locator('text=Start Your Analysis') + .or(page.locator('[data-testid="chart-ichart"]')) + .or(page.locator('[data-testid="project-dashboard"]')) + ).toBeVisible({ timeout: 15000 }); +} + +// --------------------------------------------------------------------------- +// Helper: open PasteScreen from the Editor empty state. +// --------------------------------------------------------------------------- +async function openPasteScreen(page: import('@playwright/test').Page) { + await waitForApp(page); + // "Paste Data" is the button in EditorEmptyState. + await page.getByRole('button', { name: 'Paste Data' }).first().click(); + await expect(page.getByTestId('paste-textarea')).toBeVisible({ timeout: 8000 }); +} + +// --------------------------------------------------------------------------- +// Helper: dismiss any auto-fire modals that appear after analysis confirms. +// - Factor Intelligence Preview → "Skip" button +// - Capability Suggestion ("Specification limits detected") → "Standard View" +// These modals auto-fire in fresh test contexts; dismiss before asserting canvas. +// --------------------------------------------------------------------------- +async function dismissAutoFireModals(page: import('@playwright/test').Page) { + // Factor Intelligence Preview: "Skip" button + const skipButton = page.locator('button:has-text("Skip")'); + const skipVisible = await skipButton.isVisible({ timeout: 3000 }).catch(() => false); + if (skipVisible) { + await skipButton.click(); + await skipButton.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); + } + + // Capability Suggestion modal: "Standard View" button (Specification limits detected) + const standardViewButton = page.locator('button:has-text("Standard View")'); + const capVisible = await standardViewButton.isVisible({ timeout: 3000 }).catch(() => false); + if (capVisible) { + await standardViewButton.click(); + await standardViewButton.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {}); + } + + // Close any remaining dialog with an × button + const closeButton = page.locator('button[aria-label="Close"], button:has-text("×")').first(); + const closeVisible = await closeButton.isVisible({ timeout: 1000 }).catch(() => false); + if (closeVisible) { + await closeButton.click().catch(() => {}); + } +} + +// --------------------------------------------------------------------------- +// Test group 1: Full Mode B framing via Editor paste +// --------------------------------------------------------------------------- + +test.describe('Azure Mode B framing — Editor paste flow', () => { + test('Paste Data → HubGoalForm (Stage 1) → ColumnMapping (Stage 3) → I-chart visible', async ({ + page, + }) => { + // 1. Open PasteScreen from Editor empty state + await openPasteScreen(page); + + // 2. Paste CSV and start analysis → Stage 1 (HubGoalForm) appears + await pasteDataAndAnalyze(page, MODE_B_CSV); + + // 3. Stage 1: HubCreationFlow wraps HubGoalForm in hub-creation-stage1 + await completeStage1(page, GOAL_NARRATIVE); + + // 4. Stage 3: ColumnMapping — select weight_g and confirm + await confirmColumnMapping(page, 'weight_g'); + + // 5. Analysis canvas: I-chart should appear (confirms outcome was set) + await dismissAutoFireModals(page); + await expect(page.locator('[data-testid="chart-ichart"]')).toBeVisible({ timeout: 15000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Test group 2: GoalBanner edit roundtrip via portfolio ProcessHubView +// --------------------------------------------------------------------------- + +test.describe('Azure Mode B framing — GoalBanner edit roundtrip (portfolio)', () => { + test('GoalBanner click → textarea → save → updated text (portfolio ProcessHubView)', async ({ + page, + }) => { + // 1. Complete Mode B flow to create a framed Hub + await openPasteScreen(page); + await pasteDataAndAnalyze(page, MODE_B_CSV); + await completeStage1(page, GOAL_NARRATIVE); + await confirmColumnMapping(page, 'weight_g'); + await dismissAutoFireModals(page); + // Wait for analysis to load + await expect(page.locator('[data-testid="chart-ichart"]')).toBeVisible({ timeout: 15000 }); + + // 2. Navigate to portfolio (back button requires canNavigateBack = true). + // The logo/back button aria-label is set by t('nav.backToDashboard'). + // If not accessible (no saved projects), skip. + const backButton = page.getByRole('button', { name: /back to dashboard/i }); + const portfolioAccessible = await backButton.isVisible({ timeout: 3000 }).catch(() => false); + if (!portfolioAccessible) { + test.skip(); + return; + } + + await backButton.click(); + await expect(page.locator('text=Process Hubs')).toBeVisible({ timeout: 10000 }); + + // 3. Find a hub card with a processGoal set and open its ProcessHubView + const hubCards = page.getByTestId('process-hub-card'); + const count = await hubCards.count().catch(() => 0); + if (count === 0) { + test.skip(); + return; + } + await hubCards.first().click(); + + // 4. GoalBanner is shown above the hub tabs when processGoal is set + const goalBanner = page.getByTestId('goal-banner'); + const bannerVisible = await goalBanner.isVisible({ timeout: 5000 }).catch(() => false); + if (!bannerVisible) { + test.skip(); + return; + } + + // 5. Click GoalBanner to enter edit mode (inline textarea) + await goalBanner.click(); + const goalTextarea = goalBanner.locator('textarea'); + await expect(goalTextarea).toBeVisible({ timeout: 3000 }); + + // 6. Replace goal text and save + await goalTextarea.fill(EDITED_GOAL); + await goalBanner.locator('button:has-text("Save")').click(); + + // 7. Banner should show the updated text + await expect(goalBanner).toContainText('precision medical components'); + }); +}); + +// --------------------------------------------------------------------------- +// Test group 3: "New Hub" from ProjectDashboard sidebar (action-new-hub) +// --------------------------------------------------------------------------- + +test.describe('Azure Mode B framing — ProjectDashboard "New Hub" entry point', () => { + test('"New Hub" quick-action opens Paste Data → Mode B framing flow → I-chart', async ({ + page, + }) => { + // 1. Load a sample to unlock the ProjectDashboard sidebar (needs hasData=true). + // The sample triggers HubCreationFlow Stage 1; skip it by clicking + // "Skip framing (advanced)" since we only need hasData=true for the Overview tab. + await waitForApp(page); + const sampleButton = page.locator('[data-testid^="sample-"]').first(); + const hasSample = await sampleButton.isVisible({ timeout: 5000 }).catch(() => false); + if (!hasSample) { + test.skip(); + return; + } + await sampleButton.click(); + + // Pre-configured samples (e.g. The 100-Channel Test) have outcome + factors already + // set and skip ColumnMapping entirely — the analysis starts immediately. + // Non-pre-configured samples show ColumnMapping first; handle both cases. + const mappingHeadingLocator = page.getByTestId('map-your-data-heading'); + const mappingVisible = await mappingHeadingLocator + .isVisible({ timeout: 4000 }) + .catch(() => false); + if (mappingVisible) { + // HubCreationFlow Stage 1 may appear before ColumnMapping. + const skipFramingButton = page.locator('button:has-text("Skip framing")'); + const stage1Visible = await skipFramingButton.isVisible({ timeout: 2000 }).catch(() => false); + if (stage1Visible) { + await skipFramingButton.click(); + } + await confirmColumnMapping(page); + } + + await dismissAutoFireModals(page); + await expect(page.locator('[data-testid="chart-ichart"]')).toBeVisible({ timeout: 15000 }); + + // 2. Navigate to Overview tab to show ProjectDashboard with "New Hub" button + const overviewTab = page.getByTestId('view-toggle-overview'); + const tabVisible = await overviewTab.isVisible({ timeout: 5000 }).catch(() => false); + if (!tabVisible) { + test.skip(); + return; + } + await overviewTab.click(); + + // 3. Click "New Hub" in the quick-actions area + const newHubButton = page.getByTestId('action-new-hub'); + await expect(newHubButton).toBeVisible({ timeout: 5000 }); + await newHubButton.click(); + + // 4. PasteScreen appears (handleNewHub calls showAnalysis() + startPaste()) + await expect(page.getByTestId('paste-textarea')).toBeVisible({ timeout: 8000 }); + + // 5. Complete Mode B framing flow: paste → Stage 1 HubGoalForm → ColumnMapping + // + // handlePasteAnalyze calls confirmReplaceIfNeeded() → window.confirm() when + // rawData.length > 0 && outcome is already set (from the loaded sample). + // Accept the dialog so the paste proceeds rather than aborting. + page.once('dialog', dialog => dialog.accept()); + await pasteDataAndAnalyze(page, MODE_B_CSV); + + // After fresh paste with new data, Stage 1 (HubGoalForm) always appears for + // a new investigation. Use waitFor (polling) so we don't race the React render. + const stage1AfterNewHub = page.getByTestId('hub-creation-stage1'); + try { + await stage1AfterNewHub.waitFor({ state: 'visible', timeout: 6000 }); + await completeStage1(page, GOAL_NARRATIVE); + } catch { + // Stage 1 not shown — processHubId already set; proceed to ColumnMapping. + } + await confirmColumnMapping(page, 'weight_g'); + + // 6. Analysis canvas: I-chart visible with weight_g as outcome + await dismissAutoFireModals(page); + await expect(page.locator('[data-testid="chart-ichart"]')).toBeVisible({ timeout: 15000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Test group 4: Portfolio ProcessHubView — "New Hub" → incomplete-Hub CTA +// Skips gracefully when portfolio is not accessible from a clean context. +// --------------------------------------------------------------------------- + +test.describe('Azure Mode B framing — portfolio ProcessHubView (environment-dependent)', () => { + test('"New Hub" in portfolio creates incomplete Hub with Add framing CTA', async ({ page }) => { + await waitForApp(page); + + // Portfolio is accessible only when canNavigateBack = true (saved projects exist). + const backButton = page.getByRole('button', { name: /back to dashboard/i }); + const portfolioAccessible = await backButton.isVisible({ timeout: 3000 }).catch(() => false); + if (!portfolioAccessible) { + test.skip(); + return; + } + + // Navigate to portfolio + await backButton.click(); + await expect(page.locator('text=Process Hubs')).toBeVisible({ timeout: 10000 }); + + // Click "New Hub" in the portfolio action row + await page.getByRole('button', { name: /^New Hub$/i }).click(); + + // ProcessHubView should show the incomplete-hub framing prompt + await expect(page.getByTestId('hub-framing-prompt')).toBeVisible({ timeout: 8000 }); + await expect(page.getByTestId('hub-framing-prompt-cta')).toBeVisible(); + await expect(page.getByTestId('hub-framing-prompt-cta')).toContainText('Add framing'); + }); +}); diff --git a/apps/azure/src/components/ProcessHubView.tsx b/apps/azure/src/components/ProcessHubView.tsx index da128808d..087c0b527 100644 --- a/apps/azure/src/components/ProcessHubView.tsx +++ b/apps/azure/src/components/ProcessHubView.tsx @@ -16,8 +16,10 @@ import type { ProcessStateNote, ResponsePathAction, } from '@variscout/core'; +import { isProcessHubComplete } from '@variscout/core'; import { GoalBanner, + OutcomePin, ProductionLineGlanceMigrationBanner, ProductionLineGlanceMigrationModal, } from '@variscout/ui'; @@ -46,6 +48,17 @@ export interface ProcessHubViewProps { * `processHub.reviewSignal.capability.cpkTarget`. `undefined` clears it. */ onHubCpkTargetCommit: (hubId: string, next: number | undefined) => void; + /** + * Called when the analyst edits the goal narrative inline via GoalBanner. + * Absent → GoalBanner is read-only (existing behaviour pre-Task H). + */ + onHubGoalChange?: (hubId: string, next: string) => void; + /** + * Opens the framing flow for this hub (HubCreationFlow or equivalent). + * When provided, an "Edit framing" / "Add framing" CTA is shown on hubs + * that fail isProcessHubComplete(). Absent → no CTA rendered. + */ + onEditFraming?: (hubId: string) => void; } type TabKey = 'status' | 'capability'; @@ -54,9 +67,12 @@ export const ProcessHubView: React.FC = ({ rollup, persistInvestigation, onHubCpkTargetCommit, + onHubGoalChange, + onEditFraming, ...reviewProps }) => { const [activeTab, setActiveTab] = useState('status'); + const hubIsComplete = isProcessHubComplete(rollup.hub); const migration = useHubMigrationState({ hubId: rollup.hub.id, @@ -79,11 +95,60 @@ export const ProcessHubView: React.FC = ({ empty, so unbiased Hubs (pre-Framing-Layer Hubs without processGoal) keep the existing layout untouched. - TODO(slice-2): wire `onChange` once HubCreationFlow + Hub-update - mutation hook lands. Read-only for slice 1 because ProcessHubView - does not currently receive a Hub-update callback in its props. + onChange wired to onHubGoalChange to persist inline edits (Task H). */} - + onHubGoalChange(rollup.hub.id, next) : undefined} + /> + + {/* Incomplete-hub framing prompt — shown when hub lacks goal or outcomes, + and an onEditFraming handler is wired in. Gives returning analysts a + single-click path back to the framing flow without blocking navigation. */} + {!hubIsComplete && onEditFraming && ( +
+ + This hub hasn't been framed yet — add a process goal and outcome to unlock full + analysis. + + +
+ )} + {/* OutcomePin row — one pin per outcome when hub is complete. + Stats are not available in the rollup model without a live analysis; + the pin renders in the fallback (mean ± σ + n = 0) state and shows + an "+ Add specs" chip that opens the framing flow for spec entry. */} + {hubIsComplete && rollup.hub.outcomes && rollup.hub.outcomes.length > 0 && ( +
+ {rollup.hub.outcomes.map(outcome => ( + onEditFraming?.(rollup.hub.id)} + /> + ))} +
+ )} void; /** Called once on mount to update the lastViewedAt timestamp */ onUpdateLastViewed?: () => void; + /** Mode B entry point — start framing a new investigation hub */ + onNewHub?: () => void; } // ── Component ──────────────────────────────────────────────────────────────── @@ -41,6 +43,7 @@ const ProjectDashboard: React.FC = ({ projects, onViewPortfolio, onUpdateLastViewed, + onNewHub, }) => { // Store selectors (replaces useDataStateCtx) const rawData = useProjectStore(s => s.rawData); @@ -174,6 +177,16 @@ const ProjectDashboard: React.FC = ({ Add data + {onNewHub && ( + + )} {hasFindings && ( + + + ), + ColumnMapping: ({ + onConfirm, + onCancel, + goalContext, + }: { + onConfirm: (payload: { + outcomes: Array<{ columnName: string; characteristicType: string }>; + primaryScopeDimensions: string[]; + outcome: string; + factors: string[]; + }) => void; + onCancel: () => void; + goalContext?: string; + }) => ( +
+ + +
+ ), +})); + +import { HubCreationFlow } from '../HubCreationFlow'; +import { useStorage } from '../../../services/storage'; +import type { HubCreationFlowProps } from '../HubCreationFlow'; + +const mockSaveProcessHub = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + vi.clearAllMocks(); + (useStorage as ReturnType).mockReturnValue({ + saveProcessHub: mockSaveProcessHub, + }); +}); + +const baseProps: HubCreationFlowProps = { + columnAnalysis: null, + availableColumns: ['Weight', 'Machine'], + previewRows: [{ Weight: 10, Machine: 'A' }], + totalRows: 5, + columnAliases: {}, + onColumnRename: vi.fn(), + initialOutcome: null, + initialFactors: [], + datasetName: 'Test Dataset', + onConfirm: vi.fn(), + onCancel: vi.fn(), + isMappingReEdit: false, +}; + +describe('HubCreationFlow', () => { + it('shows Stage 1 (HubGoalForm) for a new investigation', () => { + render(); + expect(screen.getByTestId('hub-creation-stage1')).toBeInTheDocument(); + expect(screen.getByTestId('hub-goal-form')).toBeInTheDocument(); + }); + + it('skips Stage 1 on re-edit and renders ColumnMapping directly', () => { + render(); + expect(screen.queryByTestId('hub-creation-stage1')).not.toBeInTheDocument(); + expect(screen.getByTestId('column-mapping')).toBeInTheDocument(); + }); + + it('skips Stage 1 when processHubId is already set', () => { + render(); + expect(screen.queryByTestId('hub-creation-stage1')).not.toBeInTheDocument(); + expect(screen.getByTestId('column-mapping')).toBeInTheDocument(); + }); + + it('advances to ColumnMapping after goal confirm', async () => { + render(); + fireEvent.click(screen.getByText('Confirm goal')); + await waitFor(() => expect(screen.getByTestId('column-mapping')).toBeInTheDocument()); + expect(screen.queryByTestId('hub-creation-stage1')).not.toBeInTheDocument(); + }); + + it('passes goalContext to ColumnMapping after goal confirm', async () => { + render(); + fireEvent.click(screen.getByText('Confirm goal')); + await waitFor(() => expect(screen.getByTestId('column-mapping')).toBeInTheDocument()); + expect(screen.getByTestId('column-mapping')).toHaveAttribute( + 'data-goal-context', + 'We mold barrels for medical customers.' + ); + }); + + it('advances to ColumnMapping on skip without goal context', async () => { + render(); + fireEvent.click(screen.getByText('Skip')); + await waitFor(() => expect(screen.getByTestId('column-mapping')).toBeInTheDocument()); + // Skipped goal → no goalContext prop on ColumnMapping + expect(screen.getByTestId('column-mapping')).not.toHaveAttribute('data-goal-context'); + }); + + it('creates a hub via saveProcessHub on goal confirm', async () => { + render(); + fireEvent.click(screen.getByText('Confirm goal')); + await waitFor(() => expect(mockSaveProcessHub).toHaveBeenCalledOnce()); + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + expect(savedHub.processGoal).toBe('We mold barrels for medical customers.'); + }); + + it('creates a hub via saveProcessHub on skip (empty goal)', async () => { + render(); + fireEvent.click(screen.getByText('Skip')); + await waitFor(() => expect(mockSaveProcessHub).toHaveBeenCalledOnce()); + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + expect(savedHub.processGoal).toBeUndefined(); + expect(savedHub.name).toBe('Untitled hub'); + }); + + it('fires onHubCreated callback after Stage 1', async () => { + const onHubCreated = vi.fn(); + render(); + fireEvent.click(screen.getByText('Confirm goal')); + await waitFor(() => expect(onHubCreated).toHaveBeenCalledOnce()); + const hub = onHubCreated.mock.calls[0][0]; + expect(hub.processGoal).toBe('We mold barrels for medical customers.'); + }); + + it('calls onConfirm when ColumnMapping confirms', async () => { + const onConfirm = vi.fn(); + render(); + fireEvent.click(screen.getByText('Confirm goal')); + await waitFor(() => expect(screen.getByTestId('column-mapping')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Confirm mapping')); + expect(onConfirm).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/azure/src/features/hubCreation/__tests__/useNewHubProvision.test.ts b/apps/azure/src/features/hubCreation/__tests__/useNewHubProvision.test.ts new file mode 100644 index 000000000..99c2712cc --- /dev/null +++ b/apps/azure/src/features/hubCreation/__tests__/useNewHubProvision.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// vi.mock BEFORE component/hook imports (anti-hang rule) +vi.mock('../../../services/storage', () => ({ + useStorage: vi.fn(), +})); + +import { useNewHubProvision } from '../useNewHubProvision'; +import { useStorage } from '../../../services/storage'; + +const mockSaveProcessHub = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + vi.clearAllMocks(); + (useStorage as ReturnType).mockReturnValue({ + saveProcessHub: mockSaveProcessHub, + }); +}); + +describe('useNewHubProvision', () => { + it('calls saveProcessHub with a hub containing the goal narrative', async () => { + const onCreated = vi.fn(); + const { result } = renderHook(() => useNewHubProvision({ onCreated })); + + await act(async () => { + await result.current.createHubFromGoal('We mold barrels for medical customers.'); + }); + + expect(mockSaveProcessHub).toHaveBeenCalledOnce(); + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + expect(savedHub.processGoal).toBe('We mold barrels for medical customers.'); + expect(savedHub.id).toBeTypeOf('string'); + expect(savedHub.id.length).toBeGreaterThan(0); + }); + + it('derives hub name from the first sentence of the goal', async () => { + const onCreated = vi.fn(); + const { result } = renderHook(() => useNewHubProvision({ onCreated })); + + await act(async () => { + await result.current.createHubFromGoal('We monitor fill weight on Line 3. Nominal is best.'); + }); + + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + expect(savedHub.name).toBeTypeOf('string'); + expect(savedHub.name.length).toBeGreaterThan(0); + }); + + it('fires onCreated with the created hub', async () => { + const onCreated = vi.fn(); + const { result } = renderHook(() => useNewHubProvision({ onCreated })); + + let returnedHub; + await act(async () => { + returnedHub = await result.current.createHubFromGoal('Mold barrel precision.'); + }); + + expect(onCreated).toHaveBeenCalledOnce(); + expect(onCreated.mock.calls[0][0]).toEqual(returnedHub); + }); + + it('creates a hub even with empty narrative (skip path)', async () => { + const onCreated = vi.fn(); + const { result } = renderHook(() => useNewHubProvision({ onCreated })); + + await act(async () => { + await result.current.createHubFromGoal(''); + }); + + expect(mockSaveProcessHub).toHaveBeenCalledOnce(); + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + expect(savedHub.processGoal).toBeUndefined(); + expect(savedHub.name).toBe('Untitled hub'); + }); + + it('returns isPending false (creation is fire-and-forget)', () => { + const { result } = renderHook(() => useNewHubProvision({ onCreated: vi.fn() })); + expect(result.current.isPending).toBe(false); + }); +}); diff --git a/apps/azure/src/features/hubCreation/index.ts b/apps/azure/src/features/hubCreation/index.ts new file mode 100644 index 000000000..9e767a323 --- /dev/null +++ b/apps/azure/src/features/hubCreation/index.ts @@ -0,0 +1,4 @@ +export { HubCreationFlow } from './HubCreationFlow'; +export type { HubCreationFlowProps } from './HubCreationFlow'; +export { useNewHubProvision } from './useNewHubProvision'; +export type { UseNewHubProvisionOptions, UseNewHubProvisionResult } from './useNewHubProvision'; diff --git a/apps/azure/src/features/hubCreation/useNewHubProvision.ts b/apps/azure/src/features/hubCreation/useNewHubProvision.ts new file mode 100644 index 000000000..f82772ead --- /dev/null +++ b/apps/azure/src/features/hubCreation/useNewHubProvision.ts @@ -0,0 +1,61 @@ +/** + * useNewHubProvision — creates and persists a new ProcessHub from a goal + * narrative during the Azure Mode B framing flow. + * + * Called by HubCreationFlow after Stage 1 (HubGoalForm) to materialise the + * Hub row before Stage 3 (ColumnMapping) runs. The created hub id is written + * to `processContext.processHubId` so subsequent saves (e.g. from + * handleMappingConfirmWithCategories) land on the correct hub. + */ +import { useCallback } from 'react'; +import { extractHubName } from '@variscout/core'; +import type { ProcessHub } from '@variscout/core/processHub'; +import { useStorage } from '../../services/storage'; + +export interface UseNewHubProvisionOptions { + /** Called with the new hub once it has been persisted. */ + onCreated: (hub: ProcessHub) => void; +} + +export interface UseNewHubProvisionResult { + /** + * Create and persist a new Hub from the given goal narrative. Returns the + * hub so callers can immediately set processContext.processHubId. + */ + createHubFromGoal: (goalNarrative: string) => Promise; + /** Whether a creation call is in-flight. */ + isPending: boolean; +} + +export function useNewHubProvision({ + onCreated, +}: UseNewHubProvisionOptions): UseNewHubProvisionResult { + const { saveProcessHub } = useStorage(); + + const createHubFromGoal = useCallback( + async (goalNarrative: string): Promise => { + const trimmed = goalNarrative.trim(); + const name = extractHubName(trimmed) || 'Untitled hub'; + const now = new Date().toISOString(); + const hub: ProcessHub = { + id: crypto.randomUUID(), + name, + processGoal: trimmed || undefined, + createdAt: now, + updatedAt: now, + }; + + await saveProcessHub(hub); + onCreated(hub); + return hub; + }, + [saveProcessHub, onCreated] + ); + + return { + createHubFromGoal, + // isPending is not tracked at this scope — callers gate the CTA button + // while the promise is in-flight using local state in HubCreationFlow. + isPending: false, + }; +} diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx index 59f8e332d..82575d1a3 100644 --- a/apps/azure/src/pages/Dashboard.tsx +++ b/apps/azure/src/pages/Dashboard.tsx @@ -24,6 +24,7 @@ import { actionToHref } from '../lib/processHubRoutes'; import { safeTrackEvent } from '../lib/appInsights'; import type { SampleDataset } from '@variscout/data'; import { useStorage, type CloudProject, downloadFileFromGraph } from '../services/storage'; +import { useNewHubProvision } from '../features/hubCreation/useNewHubProvision'; import { getEasyAuthUser } from '../auth/easyAuth'; import { Plus, @@ -77,6 +78,14 @@ export const Dashboard: React.FC = ({ const [sustainmentRecords, setSustainmentRecords] = useState([]); const [controlHandoffs, setControlHandoffs] = useState([]); const [selectedHubId, setSelectedHubId] = useState(null); + + const { createHubFromGoal } = useNewHubProvision({ + onCreated: hub => { + setProcessHubs(prev => [...prev, hub]); + setSelectedHubId(hub.id); + }, + }); + const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [isSamplePickerOpen, setIsSamplePickerOpen] = useState(false); @@ -601,6 +610,41 @@ export const Dashboard: React.FC = ({ [processHubs, saveProcessHub] ); + /** + * Persist an inline goal-narrative edit from GoalBanner back to the Hub. + * Mirrors handleHubCpkTargetCommit's optimistic-update + async-save pattern. + */ + const handleHubGoalChange = useCallback( + (hubId: string, nextGoal: string): void => { + const hub = processHubs.find(h => h.id === hubId); + if (!hub) return; + const updated: ProcessHub = { + ...hub, + processGoal: nextGoal, + updatedAt: new Date().toISOString(), + }; + setProcessHubs(prev => prev.map(h => (h.id === hubId ? updated : h))); + void saveProcessHub(updated).catch(err => { + console.error('[Dashboard] handleHubGoalChange failed:', err); + }); + }, + [processHubs, saveProcessHub] + ); + + /** + * "Edit framing" / "Add framing" CTA: re-open the Editor on the hub's + * investigation to surface HubCreationFlow. For incomplete Hubs this + * opens a new Editor entry (Mode B); for complete Hubs it could be used + * to re-enter Stage 3. We navigate via onOpenProject with the hub id so + * the Editor picks up the existing Hub context. + */ + const handleEditFraming = useCallback( + (hubId: string): void => { + onOpenProject(undefined, hubId); + }, + [onOpenProject] + ); + const handleSampleSelect = (sample: SampleDataset): void => { if (onLoadSample) { onLoadSample(sample); @@ -624,20 +668,16 @@ export const Dashboard: React.FC = ({ input.click(); }; - const handleCreateHub = async (): Promise => { - const name = window.prompt('Process Hub name'); - const trimmed = name?.trim(); - if (!trimmed) return; - const id = trimmed - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - const now = new Date().toISOString(); - const hub: ProcessHub = { id: id || `hub-${Date.now()}`, name: trimmed, createdAt: now }; - await saveProcessHub(hub); - setSelectedHubId(hub.id); - await loadProjects(); - }; + /** + * Mode B entry — create an incomplete Hub via useNewHubProvision (canonical + * creator with extractHubName). An empty goal narrative produces 'Untitled hub' + * as the fallback name. onCreated updates processHubs + selectedHubId so + * ProcessHubView's empty-state panel picks up the new hub immediately. + */ + const handleCreateHub = useCallback(async (): Promise => { + // Pass empty narrative — extractHubName returns '' → useNewHubProvision falls back to 'Untitled hub' + await createHubFromGoal(''); + }, [createHubFromGoal]); // Get sync status display const getSyncStatusDisplay = (): React.ReactNode => { @@ -829,6 +869,8 @@ export const Dashboard: React.FC = ({ onFindingSelect={handleFindingSelect} persistInvestigation={handlePersistInvestigation} onHubCpkTargetCommit={handleHubCpkTargetCommit} + onHubGoalChange={handleHubGoalChange} + onEditFraming={handleEditFraming} /> = ({ initialSample, initialProcessHubId, }) => { - const { syncStatus, listProjects, listProcessHubs, saveProject: saveToCloud } = useStorage(); + const { + syncStatus, + listProjects, + listProcessHubs, + saveProject: saveToCloud, + saveProcessHub, + } = useStorage(); const { locale } = useLocale(); const { showToast } = useToast(); @@ -704,6 +709,28 @@ export const Editor: React.FC = ({ usePanelsStore.getState().showAnalysis(); }, []); + /** + * Mode B entry: "New Hub" from the dashboard starts the paste → framing flow. + * Navigates to the analysis view so PasteScreen is visible, then opens paste. + */ + const handleNewHub = useCallback(() => { + usePanelsStore.getState().showAnalysis(); + dataFlow.startPaste(); + }, [dataFlow]); + + /** + * Called by HubCreationFlow once Stage 1 creates a hub. Adds the new hub to + * the local list and sets it as the active hub in processContext so the + * ColumnMapping confirm (Stage 3) can persist outcomes to it. + */ + const handleHubCreated = useCallback( + (hub: ProcessHub) => { + setProcessHubs(prev => [...prev, hub]); + setProcessContext(prev => ({ ...(prev ?? {}), processHubId: hub.id })); + }, + [setProcessContext] + ); + // Share handlers const { shareFinding, canMentionInChannel } = useShareFinding({ projectName, baseUrl }); @@ -1195,16 +1222,32 @@ export const Editor: React.FC = ({ } }, [isCoScoutOpen, aiOrch.coscout]); - // Pass categories and brief from ColumnMapping into DataContext + // Handle ColumnMapping confirm — adopts new Hub-shaped payload (slice-2 contract). + // Wire categories, brief, and investigation state; persist outcomes + primaryScopeDimensions + // to the active Hub via saveProcessHub (Task H will surface this on ProcessHubView — for + // Task A we wire the data path so it's available from this point forward). const handleMappingConfirmWithCategories = useCallback( - ( - newOutcome: string, - newFactors: string[], - newSpecs?: { target?: number; lsl?: number; usl?: number }, - newCategories?: InvestigationCategory[], - brief?: AnalysisBrief - ) => { + (payload: ColumnMappingConfirmPayload) => { + const { categories: newCategories, brief, outcomes, primaryScopeDimensions } = payload; + + // Derive legacy 3-arg shape for dataFlow (investigation store compat). + const newOutcome = outcomes[0]?.columnName ?? ''; + const newFactors = primaryScopeDimensions; + const firstSpec = outcomes[0]; + const newSpecs = + firstSpec && + (firstSpec.target !== undefined || + firstSpec.lsl !== undefined || + firstSpec.usl !== undefined) + ? { + ...(firstSpec.target !== undefined ? { target: firstSpec.target } : {}), + ...(firstSpec.lsl !== undefined ? { lsl: firstSpec.lsl } : {}), + ...(firstSpec.usl !== undefined ? { usl: firstSpec.usl } : {}), + } + : undefined; + if (newCategories) setCategories(newCategories); + if (brief) { const updatedContext = { ...processContext }; if (brief.issueStatement) updatedContext.issueStatement = brief.issueStatement; @@ -1220,9 +1263,39 @@ export const Editor: React.FC = ({ } } } + + // Persist outcomes + primaryScopeDimensions to the active Hub. + // The Hub is identified by processContext.processHubId; save is async + // (fire-and-forget here — ProcessHubView Task H surfaces the persisted state). + if ( + (outcomes.length > 0 || primaryScopeDimensions.length > 0) && + processContext?.processHubId + ) { + const currentHub = processHubs.find(h => h.id === processContext.processHubId); + if (currentHub) { + saveProcessHub({ + ...currentHub, + outcomes, + primaryScopeDimensions, + updatedAt: new Date().toISOString(), + }).catch(() => { + // Non-blocking — storage failure is logged by the storage service + }); + } + } + + // Delegate to investigation flow (legacy 3-arg form for importFlow compat). dataFlow.handleMappingConfirm(newOutcome, newFactors, newSpecs); }, - [dataFlow, setCategories, processContext, setProcessContext, questionsState] + [ + dataFlow, + setCategories, + processContext, + setProcessContext, + questionsState, + processHubs, + saveProcessHub, + ] ); // Compute excluded row data for DataTableModal @@ -1306,8 +1379,15 @@ export const Editor: React.FC = ({ } if (dataFlow.isMapping) { + /* + * Mode B (new investigation, not a re-edit): gate ColumnMapping behind + * Stage 1 (HubGoalForm) via HubCreationFlow. On re-edit or when a hub + * already exists the HubCreationFlow skips Stage 1 and renders + * ColumnMapping directly — same net behaviour as before. + */ + const activeHub = processHubs.find(h => h.id === processContext?.processHubId); return ( - = ({ onColumnRename={dataFlow.handleColumnRename} initialOutcome={outcome} initialFactors={factors} + initialOutcomes={activeHub?.outcomes} + initialPrimaryScopeDimensions={activeHub?.primaryScopeDimensions} datasetName={dataFilename || 'Pasted Data'} onConfirm={handleMappingConfirmWithCategories} onCancel={dataFlow.handleMappingCancel} dataQualityReport={dataQualityReport} maxFactors={6} - mode={dataFlow.isMappingReEdit ? 'edit' : 'setup'} + isMappingReEdit={dataFlow.isMappingReEdit} initialCategories={categories} timeColumn={dataFlow.timeExtractionPrompt?.timeColumn} hasTimeComponent={dataFlow.timeExtractionPrompt?.hasTimeComponent} onTimeExtractionChange={dataFlow.setTimeExtractionConfig} - showBrief={true} - initialIssueStatement={processContext?.issueStatement} suggestedStack={dataFlow.suggestedStack} onStackConfigChange={dataFlow.handleStackConfigChange} rowLimit={250000} + processHubId={processContext?.processHubId} + onHubCreated={handleHubCreated} /> ); } @@ -1456,6 +1538,7 @@ export const Editor: React.FC = ({ projects={overviewProjects} onViewPortfolio={onBack} onUpdateLastViewed={handleUpdateLastViewed} + onNewHub={handleNewHub} /> ) : activeView === 'frame' ? ( @@ -1669,7 +1752,8 @@ export const Editor: React.FC = ({ )} ) : ( - = ({ onCancel={dataFlow.handleMappingCancel} dataQualityReport={dataQualityReport} maxFactors={6} + isMappingReEdit={false} initialCategories={categories} timeColumn={dataFlow.timeExtractionPrompt?.timeColumn} hasTimeComponent={dataFlow.timeExtractionPrompt?.hasTimeComponent} onTimeExtractionChange={dataFlow.setTimeExtractionConfig} - showBrief={true} - initialIssueStatement={processContext?.issueStatement} suggestedStack={dataFlow.suggestedStack} rowLimit={250000} + processHubId={processContext?.processHubId} + onHubCreated={handleHubCreated} + initialOutcomes={processHubs.find(h => h.id === processContext?.processHubId)?.outcomes} + initialPrimaryScopeDimensions={ + processHubs.find(h => h.id === processContext?.processHubId)?.primaryScopeDimensions + } /> )} diff --git a/apps/azure/src/pages/__tests__/Dashboard.processHub.test.tsx b/apps/azure/src/pages/__tests__/Dashboard.processHub.test.tsx index e4397774b..6b984ae3d 100644 --- a/apps/azure/src/pages/__tests__/Dashboard.processHub.test.tsx +++ b/apps/azure/src/pages/__tests__/Dashboard.processHub.test.tsx @@ -384,6 +384,53 @@ describe('Dashboard Process Hub home', () => { expect(calledHubIds).toEqual(new Set(['line-5'])); }); + it('New Hub button creates an incomplete hub via useNewHubProvision without window.prompt', async () => { + mockListProjects.mockResolvedValue([]); + mockListProcessHubs.mockResolvedValue([]); + mockSaveProcessHub.mockResolvedValue(undefined); + + render(); + await waitFor(() => expect(screen.queryByText('Process Hubs')).toBeInTheDocument()); + + // No window.prompt should be called + const promptSpy = vi.spyOn(window, 'prompt'); + + fireEvent.click(screen.getByText('New Hub')); + + await waitFor(() => expect(mockSaveProcessHub).toHaveBeenCalledTimes(1)); + const savedHub = mockSaveProcessHub.mock.calls[0][0]; + // useNewHubProvision uses extractHubName('') → '' → fallback 'Untitled hub' + expect(savedHub.name).toBe('Untitled hub'); + // Incomplete — no processGoal, no outcomes + expect(savedHub.processGoal).toBeUndefined(); + expect(promptSpy).not.toHaveBeenCalled(); + + promptSpy.mockRestore(); + }); + + it('onHubGoalChange wires to saveProcessHub — framing prompt visible for incomplete hub', async () => { + mockListProjects.mockResolvedValue([makeProject()]); + mockListProcessHubs.mockResolvedValue([ + { id: 'line-4', name: 'Line 4', createdAt: '2026-04-25T00:00:00.000Z' }, + ]); + mockSaveProcessHub.mockClear(); + mockSaveProcessHub.mockResolvedValue(undefined); + + render(); + + await screen.findByText('Line 4'); + fireEvent.click(screen.getByLabelText('Open Line 4')); + + // Wait for ProcessHubView to render. + await screen.findByRole('region', { name: 'Line 4 Current Process State' }); + + // Hub has no processGoal and no outcomes → framing prompt is visible (onEditFraming wired). + // This confirms that ProcessHubView receives onEditFraming from Dashboard. + expect(screen.getByTestId('hub-framing-prompt')).toBeInTheDocument(); + // Clicking Add framing triggers onEditFraming which calls onOpenProject. + // (Full GoalBanner inline-edit saveProcessHub path tested by ProcessHubView.test.tsx) + }); + it('renders cadence column labels as eyebrow text, not as duplicate section headings', async () => { mockListProjects.mockResolvedValue([]); mockListProcessHubs.mockResolvedValue([ diff --git a/apps/azure/src/pages/__tests__/Editor.test.tsx b/apps/azure/src/pages/__tests__/Editor.test.tsx index d5728aadf..6b09a990a 100644 --- a/apps/azure/src/pages/__tests__/Editor.test.tsx +++ b/apps/azure/src/pages/__tests__/Editor.test.tsx @@ -46,6 +46,42 @@ vi.mock('../../components/ProjectDashboard', () => ({ default: () =>
ProjectDashboard
, })); +/** + * HubCreationFlow is the Mode B router. In Editor integration tests we care + * that the mapping UI surfaces — not about Stage 1 internals. Mock it to + * expose the same data-testid as ColumnMapping so existing routing tests pass. + */ +vi.mock('../../features/hubCreation', () => ({ + HubCreationFlow: ({ + onConfirm, + onCancel, + }: { + onConfirm: (payload: { + outcomes: Array<{ columnName: string; characteristicType: string }>; + primaryScopeDimensions: string[]; + outcome: string; + factors: string[]; + }) => void; + onCancel: () => void; + }) => ( +
+ + +
+ ), +})); + // ── Mock @variscout/core ── vi.mock('@variscout/core', async importOriginal => { @@ -120,11 +156,27 @@ vi.mock('@variscout/ui', () => ({ onConfirm, onCancel, }: { - onConfirm: (outcome: string, factors: string[]) => void; + onConfirm: (payload: { + outcomes: Array<{ columnName: string; characteristicType: string }>; + primaryScopeDimensions: string[]; + outcome: string; + factors: string[]; + }) => void; onCancel: () => void; }) => (
- +
), diff --git a/apps/pwa/e2e/fixtures/sample-hub.vrs b/apps/pwa/e2e/fixtures/sample-hub.vrs new file mode 100644 index 000000000..23f8cc3fc --- /dev/null +++ b/apps/pwa/e2e/fixtures/sample-hub.vrs @@ -0,0 +1,38 @@ +{ + "version": "1.0", + "exportedAt": "2026-05-04T00:00:00.000Z", + "hub": { + "id": "e2e-fixture-hub-001", + "name": "Syringe barrel moulding", + "processGoal": "We mold syringe barrels for medical customers. Weight in grams matters most.", + "createdAt": "2026-05-04T00:00:00.000Z", + "updatedAt": "2026-05-04T00:00:00.000Z", + "outcomes": [ + { + "columnName": "weight_g", + "characteristicType": "nominalIsBest", + "target": 4.5, + "usl": 4.7, + "lsl": 4.3, + "cpkTarget": 1.33 + } + ], + "primaryScopeDimensions": ["product", "shift"] + }, + "rawData": [ + { "weight_g": 4.5, "product": "A", "shift": "morning", "batch_id": "B1" }, + { "weight_g": 4.4, "product": "A", "shift": "morning", "batch_id": "B1" }, + { "weight_g": 4.6, "product": "B", "shift": "evening", "batch_id": "B2" }, + { "weight_g": 4.5, "product": "B", "shift": "evening", "batch_id": "B2" }, + { "weight_g": 4.4, "product": "A", "shift": "morning", "batch_id": "B3" }, + { "weight_g": 4.5, "product": "A", "shift": "morning", "batch_id": "B3" }, + { "weight_g": 4.6, "product": "B", "shift": "evening", "batch_id": "B4" }, + { "weight_g": 4.3, "product": "A", "shift": "morning", "batch_id": "B4" }, + { "weight_g": 4.7, "product": "B", "shift": "evening", "batch_id": "B5" }, + { "weight_g": 4.5, "product": "A", "shift": "morning", "batch_id": "B5" } + ], + "metadata": { + "exportSource": "pwa", + "appVersion": "e2e-fixture" + } +} diff --git a/apps/pwa/e2e/modeB.e2e.spec.ts b/apps/pwa/e2e/modeB.e2e.spec.ts index 189d46c7f..1e5905ff2 100644 --- a/apps/pwa/e2e/modeB.e2e.spec.ts +++ b/apps/pwa/e2e/modeB.e2e.spec.ts @@ -2,36 +2,282 @@ // // Framing layer Mode B (PWA): paste → goal narrative → outcome confirm → canvas first paint. // -// NOTE: This test is currently skipped pending full integration of the framing-layer -// flow (HubGoalForm injection between paste and column-mapping, plus canvas -// GoalBanner/OutcomePin first-paint composition). Slice 1 wires SessionProvider + -// Mode A.1 reopen end-to-end; the multi-stage paste→goal→mapping→canvas Mode B flow -// requires the column-mapping refactor that is out of scope for slice 1. -// -// Re-enable in the slice that delivers Stage 1/3 routing inside App.tsx. +// Three tests: +// 1. Full Mode B happy path + Save-to-browser + Mode A.1 reload restoration. +// 2. Cryptic (all-text) column names → OutcomeNoMatchBanner surfaces. +// 3. .vrs Import on HomeScreen → Hub + data restored (GoalBanner + OutcomePin visible). import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Shared CSV payloads +// --------------------------------------------------------------------------- + +/** 10-row syringe-barrel CSV — numeric outcome, two categoricals, one ID column */ +const SYRINGE_CSV = [ + 'weight_g,product,shift,batch_id', + '4.5,A,morning,B1', + '4.4,A,morning,B1', + '4.6,B,evening,B2', + '4.5,B,evening,B2', + '4.4,A,morning,B3', + '4.5,A,morning,B3', + '4.6,B,evening,B4', + '4.3,A,morning,B4', + '4.7,B,evening,B5', + '4.5,A,morning,B5', +].join('\n'); + +/** + * All-categorical CSV — no numeric columns. + * All candidates score below the 0.1 noMatchThreshold in buildOutcomeCandidates + * (non-numeric columns default to 0.05) → OutcomeNoMatchBanner should surface. + */ +const ALL_TEXT_CSV = [ + 'category,label,code', + 'apple,red,A1', + 'banana,yellow,B2', + 'cherry,red,C3', + 'date,brown,D4', + 'elderberry,dark,E5', +].join('\n'); + +const GOAL_NARRATIVE = + 'We mold syringe barrels for medical customers. Weight in grams matters most.'; + +// --------------------------------------------------------------------------- +// Helper: navigate to PasteScreen from HomeScreen +// --------------------------------------------------------------------------- +async function openPasteScreen(page: import('@playwright/test').Page) { + await page.goto('/'); + // Wait for HomeScreen + await expect(page.getByTestId('home-paste-button')).toBeVisible({ timeout: 10000 }); + await page.getByTestId('home-paste-button').click(); + // PasteScreen + await expect(page.getByTestId('paste-textarea')).toBeVisible({ timeout: 5000 }); +} + +// --------------------------------------------------------------------------- +// Test 1: Full Mode B happy path +// --------------------------------------------------------------------------- + +test.describe('Framing layer Mode B (PWA) — happy path', () => { + test('paste → goal narrative → outcome confirm → GoalBanner + OutcomePin + Save chip visible', async ({ + page, + }) => { + // 1. Open PWA + await openPasteScreen(page); + + // 2. Paste CSV data + await page.getByTestId('paste-textarea').fill(SYRINGE_CSV); + await page.getByTestId('paste-start-analysis').click(); + + // 3. Stage 1: HubGoalForm is shown before ColumnMapping + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill(GOAL_NARRATIVE); + // Click Continue → + await page.getByRole('button', { name: /Continue/i }).click(); + + // 4. Stage 3: ColumnMapping appears (Map Your Data heading) + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + + // weight_g should be a candidate in the list + await expect(page.getByTestId('outcome-candidate-list')).toBeVisible({ timeout: 5000 }); + const weightRadio = page + .getByTestId('outcome-candidate-list') + .locator('input[type="checkbox"][aria-label="weight_g"]'); + await expect(weightRadio).toBeVisible({ timeout: 5000 }); + + // Select weight_g if not already checked + const alreadyChecked = await weightRadio.isChecked().catch(() => false); + if (!alreadyChecked) { + await weightRadio.click(); + } + + // Confirm — click "Start Analysis" + await page.locator('button:has-text("Start Analysis")').first().click(); + + // 5. Workspace assertions: GoalBanner + OutcomePin + framing toolbar + await expect(page.getByTestId('goal-banner')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('goal-banner')).toContainText('We mold syringe barrels'); + + await expect(page.getByTestId('outcome-pin').first()).toBeVisible({ timeout: 8000 }); + await expect(page.getByTestId('outcome-pin').first()).toContainText('weight_g'); + + // framing toolbar is visible (contains Save + Export + Edit framing) + await expect(page.getByTestId('framing-toolbar')).toBeVisible({ timeout: 5000 }); + + // Save-to-browser button visible (not yet opted in) + await expect(page.getByTestId('save-to-browser-button')).toBeVisible({ timeout: 5000 }); + }); + + test('Save to browser → opt-in → reload → Mode A.1 restores GoalBanner', async ({ page }) => { + // 1. Open PWA and perform the full Mode B flow + await openPasteScreen(page); + await page.getByTestId('paste-textarea').fill(SYRINGE_CSV); + await page.getByTestId('paste-start-analysis').click(); + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill(GOAL_NARRATIVE); + await page.getByRole('button', { name: /Continue/i }).click(); + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + await page.locator('button:has-text("Start Analysis")').first().click(); + await expect(page.getByTestId('goal-banner')).toBeVisible({ timeout: 10000 }); + + // 2. Click Save to browser + await expect(page.getByTestId('save-to-browser-button')).toBeVisible({ timeout: 5000 }); + await page.getByTestId('save-to-browser-button').click(); + // Button should change to the "Saved · Forget" variant + await expect(page.getByTestId('save-to-browser-saved')).toBeVisible({ timeout: 5000 }); + + // 3. Reload — Mode A.1 restoration: Hub (processGoal) is restored from IndexedDB. + // Raw data is NOT restored (session-only); the GoalBanner still shows above HomeScreen. + await page.reload(); + await page.waitForLoadState('networkidle'); + + // GoalBanner still shows the goal (Hub restored from IndexedDB) + await expect(page.getByTestId('goal-banner')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('goal-banner')).toContainText('We mold syringe barrels'); + + // HomeScreen is shown (no data loaded yet) + await expect(page.getByTestId('home-paste-button')).toBeVisible({ timeout: 5000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Test: Multi-outcome confirm path — select 2 checkboxes, confirm, assert 2 OutcomePins +// --------------------------------------------------------------------------- + +const TWO_OUTCOME_CSV = [ + 'weight_g,length_mm,product,shift', + '4.5,12.1,A,morning', + '4.4,11.9,A,morning', + '4.6,12.3,B,evening', + '4.5,12.0,B,evening', + '4.4,11.8,A,morning', +].join('\n'); + +test.describe('Framing layer Mode B (PWA) — multi-outcome confirm', () => { + test('select 2 outcome checkboxes → workspace shows 2 OutcomePins', async ({ page }) => { + await openPasteScreen(page); -test.describe('Framing layer Mode B (PWA)', () => { - test.skip('paste → goal narrative → outcome confirm → canvas first paint', async ({ page }) => { + await page.getByTestId('paste-textarea').fill(TWO_OUTCOME_CSV); + await page.getByTestId('paste-start-analysis').click(); + + // Stage 1 + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill('Analyze weight and length.'); + await page.getByRole('button', { name: /Continue/i }).click(); + + // Stage 3: ColumnMapping + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + await expect(page.getByTestId('outcome-candidate-list')).toBeVisible({ timeout: 5000 }); + + // Select weight_g checkbox + const weightCheckbox = page + .getByTestId('outcome-candidate-list') + .locator('input[type="checkbox"][aria-label="weight_g"]'); + const weightChecked = await weightCheckbox.isChecked().catch(() => false); + if (!weightChecked) { + await weightCheckbox.click(); + } + + // Select length_mm checkbox + const lengthCheckbox = page + .getByTestId('outcome-candidate-list') + .locator('input[type="checkbox"][aria-label="length_mm"]'); + await expect(lengthCheckbox).toBeVisible({ timeout: 5000 }); + const lengthChecked = await lengthCheckbox.isChecked().catch(() => false); + if (!lengthChecked) { + await lengthCheckbox.click(); + } + + // Confirm + await page.locator('button:has-text("Start Analysis")').first().click(); + + // Workspace should show 2 OutcomePins + await expect(page.getByTestId('outcome-pin')).toHaveCount(2, { timeout: 10000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Test 2: Cryptic column names → OutcomeNoMatchBanner +// --------------------------------------------------------------------------- + +test.describe('Framing layer Mode B (PWA) — cryptic column names', () => { + test('all-text columns + vague goal → OutcomeNoMatchBanner surfaces', async ({ page }) => { + await openPasteScreen(page); + + // Paste all-categorical CSV (no numeric columns → all candidates score 0.05 < 0.1 threshold) + await page.getByTestId('paste-textarea').fill(ALL_TEXT_CSV); + await page.getByTestId('paste-start-analysis').click(); + + // Stage 1: HubGoalForm + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill('We make widgets.'); + await page.getByRole('button', { name: /Continue/i }).click(); + + // Stage 3: ColumnMapping + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + + // OutcomeNoMatchBanner should be visible (role=alert, text starts with "⚠ No clear outcome match") + await expect(page.getByRole('alert')).toBeVisible({ timeout: 8000 }); + await expect(page.getByRole('alert')).toContainText('No clear outcome match'); + }); + + test('OutcomeNoMatchBanner Skip → workspace renders without OutcomePin', async ({ page }) => { + await openPasteScreen(page); + + await page.getByTestId('paste-textarea').fill(ALL_TEXT_CSV); + await page.getByTestId('paste-start-analysis').click(); + + // Stage 1 + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill('We make widgets.'); + await page.getByRole('button', { name: /Continue/i }).click(); + + // Stage 3: ColumnMapping — banner should appear + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + await expect(page.getByRole('alert')).toBeVisible({ timeout: 8000 }); + + // Click Skip — clears all selected outcomes + await page.getByRole('button', { name: /Skip outcome/i }).click(); + + // Start Analysis should now be disabled (no outcome selected) + const startBtn = page.locator('button:has-text("Start Analysis")').first(); + await expect(startBtn).toBeDisabled({ timeout: 3000 }); + }); +}); + +// --------------------------------------------------------------------------- +// Test 3: .vrs Import on HomeScreen → GoalBanner + OutcomePin +// --------------------------------------------------------------------------- + +test.describe('Framing layer Mode B (PWA) — .vrs Import', () => { + test('import .vrs fixture → Hub goal and outcome pin visible on canvas', async ({ page }) => { await page.goto('/'); - await page.click('text=Paste from Excel'); - await page - .getByRole('textbox', { name: /paste data/i }) - .fill('weight_g,oven_temp\n4.5,178\n4.4,180\n4.6,180\n4.5,179\n4.4,178'); - await page.click('text=Parse'); - - // Stage 1: goal narrative - await page - .getByRole('textbox', { name: /process goal/i }) - .fill('We mold barrels for medical customers.'); - await page.click('text=Continue'); - - // Stage 3: outcome auto-selected via goal context - await expect(page.getByRole('radio', { name: /weight_g/i })).toBeChecked(); - await page.click('text=Confirm'); - - // Stage 4: canvas first paint - await expect(page.getByTestId('goal-banner')).toContainText('We mold barrels'); - await expect(page.getByTestId('outcome-pin')).toContainText('weight_g'); + await expect(page.getByTestId('home-paste-button')).toBeVisible({ timeout: 10000 }); + + // The VrsImportButton is rendered on HomeScreen when onImportVrs is wired. + // Use Playwright's file chooser API to upload the fixture. + const fixturePath = path.join(__dirname, 'fixtures', 'sample-hub.vrs'); + + // Click the "Choose .vrs file" button and intercept the file dialog + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByTestId('vrs-import-button').click(), + ]); + await fileChooser.setFiles(fixturePath); + + // After import, the app skips framing and goes straight to canvas with Hub state. + // GoalBanner should contain the fixture's processGoal. + await expect(page.getByTestId('goal-banner')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('goal-banner')).toContainText('We mold syringe barrels'); + + // OutcomePin for weight_g should be visible (rawData was also restored from the fixture) + await expect(page.getByTestId('outcome-pin').first()).toBeVisible({ timeout: 8000 }); + await expect(page.getByTestId('outcome-pin').first()).toContainText('weight_g'); }); }); diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index a0226b624..5622e94bb 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -4,6 +4,7 @@ import { lazyWithRetry } from './lib/chunkReload'; import { useFilterNavigation } from './hooks/useFilterNavigation'; import { ColumnMapping, + type ColumnMappingConfirmPayload, FindingsWindow, openFindingsPopout, updateFindingsPopout, @@ -20,7 +21,10 @@ import { QuestionLinkPrompt, GoalBanner, HubGoalForm, + OutcomePin, } from '@variscout/ui'; +import { SaveToBrowserButton } from './components/SaveToBrowserButton'; +import { VrsExportButton } from './components/VrsExportButton'; import { SessionProvider, useSession } from './store/sessionStore'; import { hubRepository } from './db/hubRepository'; import { Beaker, Settings, Download, Table2, RotateCcw, FileText } from 'lucide-react'; @@ -630,38 +634,74 @@ function AppMain() { setQuestionLinkPromptOpen(false); }, []); - // Mode B: when ColumnMapping confirms, fold the Stage 1 narrative into the - // session Hub so the GoalBanner picks it up immediately. Slice 1 keeps the - // Hub minimal (id + name + processGoal + createdAt); the slice-2 refactor - // will populate `outcomes` / `primaryScopeDimensions` from the new Stage 3 - // mapping rows. We preserve any pre-existing sessionHub fields (e.g. when - // restored from opt-in persistence — Mode A.1) by spreading first. + // Mode B: when ColumnMapping confirms, fold the Stage 1 narrative + Stage 3 + // Hub-shaped payload (outcomes, primaryScopeDimensions) into the session Hub + // so the GoalBanner picks it up immediately. Preserve any pre-existing + // sessionHub fields (Mode A.1 restore path) by spreading first. const handleMappingConfirmWithGoal = useCallback( - ( - newOutcome: string, - newFactors: string[], - newSpecs?: { target?: number; lsl?: number; usl?: number } - ) => { - importFlow.handleMappingConfirm(newOutcome, newFactors, newSpecs); - if (goalNarrative && goalNarrative.trim()) { - const base = sessionHub ?? { - id: crypto.randomUUID(), - name: '', - createdAt: new Date().toISOString(), - }; - setSessionHub({ - ...base, - name: extractHubName(goalNarrative) || base.name || 'Untitled hub', - processGoal: goalNarrative, - updatedAt: new Date().toISOString(), - }); - } - // TODO(slice-2): wire outcomes[] + primaryScopeDimensions into Hub - // construction once Stage 3 ColumnMapping refactor lands. + (payload: ColumnMappingConfirmPayload) => { + // Delegate legacy investigation flow (importFlow still takes the 3-arg form). + // Derive single-outcome and factors from the Hub-shaped payload. + const firstOutcome = payload.outcomes[0]?.columnName ?? ''; + const legacyFactors = payload.primaryScopeDimensions; + const firstSpec = payload.outcomes[0]; + const legacySpecs = + firstSpec && + (firstSpec.target !== undefined || + firstSpec.lsl !== undefined || + firstSpec.usl !== undefined) + ? { + ...(firstSpec.target !== undefined ? { target: firstSpec.target } : {}), + ...(firstSpec.lsl !== undefined ? { lsl: firstSpec.lsl } : {}), + ...(firstSpec.usl !== undefined ? { usl: firstSpec.usl } : {}), + } + : undefined; + importFlow.handleMappingConfirm(firstOutcome, legacyFactors, legacySpecs); + + const base = sessionHub ?? { + id: crypto.randomUUID(), + name: '', + createdAt: new Date().toISOString(), + }; + + const goalNarrativeForHub = goalNarrative && goalNarrative.trim() ? goalNarrative : undefined; + + setSessionHub({ + ...base, + ...(goalNarrativeForHub + ? { + name: extractHubName(goalNarrativeForHub) || base.name || 'Untitled hub', + processGoal: goalNarrativeForHub, + } + : {}), + // Wire outcomes + primaryScopeDimensions into the Hub (resolves slice-1 TODO). + outcomes: payload.outcomes, + primaryScopeDimensions: payload.primaryScopeDimensions, + updatedAt: new Date().toISOString(), + }); }, [importFlow, goalNarrative, sessionHub, setSessionHub] ); + // .vrs import: restore Hub + raw data, skip framing flow, go straight to canvas. + // Wired to HomeScreen's onImportVrs prop so trainers / returning analysts can + // reload a packaged scenario without re-pasting data. + const handleImportVrs = useCallback( + (imported: import('@variscout/core').VrsFile) => { + const { hub, rawData: vrsData } = imported; + setSessionHub(hub); + // Seed the project store directly — bypasses the paste/mapping flow. + if (vrsData && vrsData.length > 0) { + setRawData(vrsData as import('@variscout/core').DataRow[]); + const firstOutcome = hub.outcomes?.[0]?.columnName; + if (firstOutcome) setOutcome(firstOutcome); + const dims = hub.primaryScopeDimensions ?? []; + if (dims.length > 0) setFactors(dims); + } + }, + [setSessionHub, setRawData, setOutcome, setFactors] + ); + // Phase tab navigation handler (used by AppHeader inline tabs) const handlePhaseChange = useCallback( (phase: PhaseId) => { @@ -820,8 +860,62 @@ function AppMain() { )} {/* Goal banner — surfaces the Hub processGoal when restored from - opt-in persistence (Mode A.1) or set via the framing layer flow. */} - {sessionHub?.processGoal ? : null} + opt-in persistence (Mode A.1) or set via the framing layer flow. + onChange lets the analyst edit the goal inline; updates sessionHub. */} + {sessionHub?.processGoal ? ( + { + setSessionHub({ + ...sessionHub, + processGoal: next, + updatedAt: new Date().toISOString(), + }); + }} + /> + ) : null} + + {/* Canvas framing toolbar — visible when data is loaded and we are on the + analysis canvas (not in a framing modal). Shows OutcomePin, Save-to-browser, + .vrs export, and Edit-framing re-entry. */} + {rawData.length > 0 && + !importFlow.isPasteMode && + !importFlow.isManualEntry && + !importFlow.isMapping && + sessionHub && ( +
+ {/* OutcomePin per outcome — one pin per outcome in sessionHub.outcomes. + Falls back to mean=0/sigma=0 when analysis stats are not yet ready. */} + {sessionHub.outcomes && + sessionHub.outcomes.length > 0 && + sessionHub.outcomes.map(outcomeEntry => ( + importFlow.openFactorManager()} + /> + ))} +
+ + + +
+ )} {/* Main Content */}
@@ -870,6 +964,7 @@ function AppMain() { onLoadSample={ingestion.loadSample} onOpenPaste={importFlow.handleOpenPaste} onOpenManualEntry={importFlow.handleOpenManualEntry} + onImportVrs={handleImportVrs} /> ) : importFlow.isMapping && goalNarrative === null ? ( // Mode B Stage 1: ask for the process goal narrative before @@ -892,6 +987,14 @@ function AppMain() { onColumnRename={importFlow.handleColumnRename} initialOutcome={outcome} initialFactors={factors} + initialOutcomes={ + importFlow.isMappingReEdit ? (sessionHub?.outcomes ?? undefined) : undefined + } + initialPrimaryScopeDimensions={ + importFlow.isMappingReEdit + ? (sessionHub?.primaryScopeDimensions ?? undefined) + : undefined + } datasetName={dataFilename || undefined} onConfirm={handleMappingConfirmWithGoal} onCancel={importFlow.handleMappingCancel} diff --git a/apps/pwa/src/__tests__/outcomePinMulti.test.tsx b/apps/pwa/src/__tests__/outcomePinMulti.test.tsx new file mode 100644 index 000000000..7f48c242e --- /dev/null +++ b/apps/pwa/src/__tests__/outcomePinMulti.test.tsx @@ -0,0 +1,162 @@ +// apps/pwa/src/__tests__/outcomePinMulti.test.tsx +// +// Verifies that the framing toolbar renders one OutcomePin per outcome entry +// in sessionHub.outcomes (not just outcomes[0]). +// +// vi.mock() BEFORE imports — testing.md invariant. +import 'fake-indexeddb/auto'; +import { vi } from 'vitest'; + +// Stub heavy components to keep renders fast in jsdom. +vi.mock('../components/Dashboard', () => ({ + 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
, +})); + +// Stub the stats worker so useAnalysisStats returns a value immediately. +vi.mock('../workers/useStatsWorker', () => ({ + useStatsWorker: () => ({ + computeStats: vi.fn(), + computeStatsAsync: vi.fn().mockResolvedValue({ + mean: 23.5, + stdDev: 0.5, + min: 22, + max: 25, + q1: 23, + q3: 24, + median: 23.5, + cpk: 1.2, + skewness: 0, + kurtosis: 3, + n: 10, + outOfSpecCount: 0, + outOfSpecPercentage: 0, + nelsonFailed: [], + nelsonRule2Sequences: [], + nelsonRule3Sequences: [], + }), + }), +})); + +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import App from '../App'; +import { LocaleProvider } from '../context/LocaleContext'; +import { hubRepository } from '../db/hubRepository'; +import { useProjectStore } from '@variscout/stores'; +import { registerLocaleLoaders, type MessageCatalog } from '@variscout/core'; + +registerLocaleLoaders( + import.meta.glob>( + '../../../../packages/core/src/i18n/messages/*.ts', + { eager: false } + ) +); + +function renderApp() { + return render( + + + + ); +} + +describe('PWA framing toolbar — OutcomePin per outcome', () => { + beforeEach(async () => { + await hubRepository.clearAll(); + // Reset the project store so rawData and outcome are cleared between tests. + useProjectStore.setState({ + rawData: [], + outcome: null, + factors: [], + }); + }); + + it('renders one OutcomePin for a single-outcome hub with data', async () => { + await hubRepository.setOptInFlag(true); + await hubRepository.saveHub({ + id: 'test-hub', + name: 'Test Hub', + createdAt: new Date().toISOString(), + processGoal: 'Single outcome hub.', + outcomes: [{ columnName: 'FillWeight', characteristicType: 'nominalIsBest' }], + }); + + // Seed raw data so the framing toolbar becomes visible. + useProjectStore.setState({ + rawData: [{ FillWeight: 23.5 }, { FillWeight: 24.1 }], + outcome: 'FillWeight', + }); + + renderApp(); + + await waitFor( + () => { + const pins = screen.getAllByTestId('outcome-pin'); + expect(pins).toHaveLength(1); + }, + { timeout: 4000 } + ); + }); + + it('renders two OutcomePins for a two-outcome hub with data', async () => { + await hubRepository.setOptInFlag(true); + await hubRepository.saveHub({ + id: 'test-hub-2', + name: 'Test Hub 2', + createdAt: new Date().toISOString(), + processGoal: 'Multi-outcome hub.', + outcomes: [ + { columnName: 'FillWeight', characteristicType: 'nominalIsBest' }, + { columnName: 'CycleTime', characteristicType: 'smallerIsBetter' }, + ], + }); + + useProjectStore.setState({ + rawData: [ + { FillWeight: 23.5, CycleTime: 5.2 }, + { FillWeight: 24.1, CycleTime: 5.4 }, + ], + outcome: 'FillWeight', + }); + + renderApp(); + + await waitFor( + () => { + const pins = screen.getAllByTestId('outcome-pin'); + expect(pins).toHaveLength(2); + }, + { timeout: 4000 } + ); + }); +}); diff --git a/apps/pwa/src/components/HomeScreen.tsx b/apps/pwa/src/components/HomeScreen.tsx index 745ed731c..048453cfb 100644 --- a/apps/pwa/src/components/HomeScreen.tsx +++ b/apps/pwa/src/components/HomeScreen.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { BarChart2, ClipboardPaste, PenLine, ArrowUpRight } from 'lucide-react'; import type { SampleDataset } from '@variscout/data'; import { useTranslation } from '@variscout/hooks'; +import type { VrsFile } from '@variscout/core'; +import { VrsImportButton } from './VrsImportButton'; import SampleSection from './data/SampleSection'; interface HomeScreenProps { @@ -9,6 +11,7 @@ interface HomeScreenProps { onOpenPaste: () => void; onOpenManualEntry: () => void; onOpenSettings?: () => void; + onImportVrs?: (imported: VrsFile) => void; } /** @@ -20,6 +23,7 @@ const HomeScreen: React.FC = ({ onLoadSample, onOpenPaste, onOpenManualEntry, + onImportVrs, }) => { const { t } = useTranslation(); @@ -68,7 +72,7 @@ const HomeScreen: React.FC = ({
- {/* Secondary: manual entry link */} + {/* Secondary: manual entry + .vrs import */}
+ {onImportVrs && ( +
+ +
+ )}
{ setBusy(true); @@ -45,6 +46,7 @@ export function SaveToBrowserButton({ currentHub }: SaveToBrowserButtonProps) { return ( diff --git a/docs/decision-log.md b/docs/decision-log.md index 7fa88ff35..af7d21031 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -34,6 +34,8 @@ Decisions we keep relitigating. Each entry: short statement, rationale, closing - **ADR-074 — SCOUT level-spanning surface boundary policy.** Each surface owns exactly one level of the three-level methodology (L1 outcome / L2 flow / L3 mechanism) and lenses the other two by linking to the surface that owns each level — never by re-rendering or recomputing. SCOUT owns L1 outcome reading; FRAME owns L2 authoring; the Hub Capability tab owns L2 reading; Investigation Wall owns L3 hypothesis canvas; Evidence Map owns L3 factor network; INVESTIGATE owns L3 case-building. Same enforcement mechanism as ADR-073: structural absence + CI guards, not permission predicates. Prevents the four concrete temptations during implementation (SCOUT redoing column mapping, INVESTIGATE recomputing outcome stats, Hub Capability tab implementing its own SuspectedCause UI, Evidence Map maintaining its own boxplots). _Closed 2026-04-29._ Source: [`docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md`](07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md); companion design at [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md). +- **2026-05-04 — Slice 2: ProcessHubView empty-state implementation deviates from inline-panel design.** The plan specified "ProcessHubView renders HubCreationFlow inline when Hub is incomplete." Shipped implementation uses an amber CTA in ProcessHubView that redirects to Editor's existing paste/upload flow, which then renders HubCreationFlow Stages 1→2→3 inside the Editor gate. **Decision: ship the redirect-to-Editor-paste-flow path; do NOT block slice 2 on re-implementing inline-panel.** Rationale: Editor.tsx already owns paste/upload infrastructure; duplicating it into ProcessHubView would have required reimplementing the full paste pipeline inline. The user reaches HubCreationFlow Stages 1→2→3 either way — the route is the only difference. **Supersedes** plan section "Azure Hub-creation entry: empty-state inline panel" in `docs/superpowers/plans/lets-do-slice-2-synchronous-sonnet.md`. **Carry-forward:** revisit when ProcessHubView gains its canvas surface (Spec 2 territory) — the inline-panel design is the natural home for canvas creation once the canvas exists. Also carry-forward: `expectedOutcomeNote` field in `ColumnMappingConfirmPayload` has no home on `ProcessHub` yet — downstream handlers currently ignore it. Add to `ProcessHub` metadata when the field lands. _Pinned 2026-05-04._ + - **2026-05-03 — VariScout product vision consolidated.** One canonical vision spec at [`docs/superpowers/specs/2026-05-03-variscout-vision-design.md`](superpowers/specs/2026-05-03-variscout-vision-design.md) supersedes the 2026-04-27 `process-learning-operating-model-design.md` and `product-method-roadmap-design.md` (both moved to `docs/archive/specs/` with `status: superseded` + forward pointer). `docs/01-vision/methodology.md` retained as longer-form companion with a forward-pointer banner; reconciliation is a follow-up edit. **Core thesis:** "the map is the product" — a Process Hub IS its logic map; one continuous canvas (DAG with branch + join + two-level nesting + context propagation) replaces today's FRAME workspace components (`ProcessMapBase` river-SIPOC, `LayeredProcessView`, `LayeredProcessViewWithCapability`); cards-with-mini-charts per step + drill-down panel + mode lenses replace the separate Analysis tab; "tributary" / "CTS" jargon retired. **10 canvas commitments** in spec §3.3 are load-bearing. **11 open questions in §8** carry brainstorm defaults that need explicit confirmation before implementation plans are written. Engine + data model survive (production-line-glance C2's per-(node × context-tuple) capability is the math under the canvas). Brainstorm transcript at `~/.claude/plans/i-would-like-to-composed-rose.md`. _Pinned 2026-05-03._ - **2026-05-03 — Q8 revised: PWA persistence opt-in instead of default-on; `.vrs` files double as shareable training scenarios.** Original Q8 ("PWA = local Hub-of-one with IndexedDB persistence") was too aggressive — it would have surprised users on shared computers and conflated "PWA _can_ persist" with "PWA _must_ persist." Revised Q8 (Option 4 hybrid): session-only by default; opt-in via "Save to this browser" for IndexedDB-backed Hub-of-one AND `.vrs` file export/import always available. Both paths preserved as user-agency escape hatches. **Strategic rationale:** PWA serves LSSGB training, demos, casual personal analysis, and **trainers authoring custom scenarios for their students**. Trainers package datasets + Hub state + sample investigations into a `.vrs` bundle and share via LMS / email; students import the bundle to start from a prepared training state. This positions PWA as the methodology-teaching surface and `.vrs` as the scenario-distribution format. Each persona's persistence consent is explicit (training students opt in once and auto-save; demo users skip; privacy-conscious users export to file; trainers export+share). Companies still use Azure tier for centralized + secure persistence per ADR-059. Constitution P1 (browser-only processing) and P8 (no AI in free tier) preserved. `apps/pwa/CLAUDE.md` hard rule updated from "no persistence" to "session-only by default; opt-in IndexedDB allowed; `.vrs` import/export for trainer-shared scenarios." Vision spec §7 tier paragraph + §8 Q8 row updated. Framing-layer spec V1 scope expands to include opt-in "Save to browser" affordance + `.vrs` export/import + IndexedDB schema loaded post-opt-in. _Pinned 2026-05-03._ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f357083c3..c4aeb5ab0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -512,6 +512,7 @@ export { buildProcessHubReview, buildProcessHubRollups, investigationStatusFromJourneyPhase, + isProcessHubComplete, normalizeProcessHubId, } from './processHub'; export { buildCurrentProcessState } from './processState'; diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index 14dbadea9..fccea58ef 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -429,6 +429,27 @@ export function normalizeProcessHubId(processHubId?: string | null): string { return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_PROCESS_HUB_ID; } +/** + * Returns `true` when a ProcessHub contains the minimum framing-layer fields + * required for canvas first paint (Mode A.1 reopen path). + * + * Required: + * - `processGoal` — non-empty string (the goal narrative was stated) + * - `outcomes` — at least one OutcomeSpec with a non-empty `columnName` + * + * `primaryScopeDimensions` is intentionally NOT required: the analyst may have + * skipped the sub-step (empty array is valid, absent is valid — both mean + * "no explicit scope dimensions chosen"). + */ +export function isProcessHubComplete(hub: ProcessHub): boolean { + const hasGoal = typeof hub.processGoal === 'string' && hub.processGoal.trim().length > 0; + const hasOutcome = + Array.isArray(hub.outcomes) && + hub.outcomes.length > 0 && + hub.outcomes.every(o => typeof o.columnName === 'string' && o.columnName.trim().length > 0); + return hasGoal && hasOutcome; +} + export function investigationStatusFromJourneyPhase(phase: JourneyPhase): InvestigationStatus { switch (phase) { case 'frame': diff --git a/packages/core/src/processHub/__tests__/processHubFields.test.ts b/packages/core/src/processHub/__tests__/processHubFields.test.ts index 000e55c9a..e3cc5e183 100644 --- a/packages/core/src/processHub/__tests__/processHubFields.test.ts +++ b/packages/core/src/processHub/__tests__/processHubFields.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { ProcessHub, OutcomeSpec } from '../../processHub'; -import { DEFAULT_PROCESS_HUB } from '../../processHub'; +import { DEFAULT_PROCESS_HUB, isProcessHubComplete } from '../../processHub'; describe('ProcessHub framing-layer fields', () => { it('accepts a process goal narrative', () => { @@ -36,3 +36,84 @@ describe('ProcessHub framing-layer fields', () => { expect(hub.primaryScopeDimensions).toBeUndefined(); }); }); + +describe('isProcessHubComplete', () => { + const baseOutcome: OutcomeSpec = { + columnName: 'weight_g', + characteristicType: 'nominalIsBest', + }; + + it('returns true when processGoal and at least one outcome are present', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels for medical customers.', + outcomes: [baseOutcome], + }; + expect(isProcessHubComplete(hub)).toBe(true); + }); + + it('returns false when processGoal is absent', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + outcomes: [baseOutcome], + }; + expect(isProcessHubComplete(hub)).toBe(false); + }); + + it('returns false when processGoal is empty string', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: ' ', + outcomes: [baseOutcome], + }; + expect(isProcessHubComplete(hub)).toBe(false); + }); + + it('returns false when outcomes array is absent', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels.', + }; + expect(isProcessHubComplete(hub)).toBe(false); + }); + + it('returns false when outcomes array is empty', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels.', + outcomes: [], + }; + expect(isProcessHubComplete(hub)).toBe(false); + }); + + it('returns false when an outcome has an empty columnName', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels.', + outcomes: [{ columnName: ' ', characteristicType: 'nominalIsBest' }], + }; + expect(isProcessHubComplete(hub)).toBe(false); + }); + + it('does not require primaryScopeDimensions', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels.', + outcomes: [baseOutcome], + // primaryScopeDimensions intentionally omitted + }; + expect(isProcessHubComplete(hub)).toBe(true); + }); + + it('accepts multiple outcomes', () => { + const hub: ProcessHub = { + ...DEFAULT_PROCESS_HUB, + processGoal: 'We mold barrels.', + outcomes: [ + { columnName: 'weight_g', characteristicType: 'nominalIsBest' }, + { columnName: 'length_mm', characteristicType: 'smallerIsBetter' }, + ], + }; + expect(isProcessHubComplete(hub)).toBe(true); + }); +}); diff --git a/packages/ui/src/components/ColumnMapping/__tests__/ColumnMapping.test.tsx b/packages/ui/src/components/ColumnMapping/__tests__/ColumnMapping.test.tsx index 4045223d7..b7d58de62 100644 --- a/packages/ui/src/components/ColumnMapping/__tests__/ColumnMapping.test.tsx +++ b/packages/ui/src/components/ColumnMapping/__tests__/ColumnMapping.test.tsx @@ -1,6 +1,23 @@ -import { describe, it, expect, vi } from 'vitest'; +/** + * ColumnMapping tests — slice-2 contract. + * + * The new onConfirm shape is ColumnMappingConfirmPayload (Hub-shaped). + * The outcome section uses OutcomeCandidateRow (multi-select via radio/toggle). + * The scope section uses PrimaryScopeDimensionsSelector. + * OutcomeNoMatchBanner surfaces when all candidates score below threshold. + * mode='edit' preloads existing Hub data (initialOutcomes + initialPrimaryScopeDimensions). + * + * Legacy single-outcome assertion: `payload.outcome` carries the first outcome's + * columnName for importFlow compat; `payload.factors` carries legacy factor columns. + * + * IMPORTANT: vi.mock() MUST appear before component imports (anti-hang rule). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +// ── Mocks (BEFORE component imports) ────────────────────────────────────────── + vi.mock('@variscout/hooks', () => { const catalog: Record = { 'data.mapHeading': 'Map Your Data', @@ -56,10 +73,13 @@ vi.mock('@variscout/hooks', () => { }; }); -import { ColumnMapping } from '../index'; -import type { ColumnAnalysis, InvestigationCategory } from '@variscout/core'; +// ── Component import (after mocks) ──────────────────────────────────────────── + +import { ColumnMapping, type ColumnMappingConfirmPayload } from '../index'; +import type { ColumnAnalysis } from '@variscout/core'; +import type { OutcomeSpec } from '@variscout/core/processHub'; -// --- Helpers --- +// ── Helpers ─────────────────────────────────────────────────────────────────── /** Build a minimal ColumnAnalysis stub */ function col( @@ -100,395 +120,485 @@ const richProps = { onCancel: vi.fn(), }; -/** Legacy props using plain column names */ -const legacyProps = { - availableColumns: ['Value', 'Machine', 'Shift', 'Operator', 'Line', 'Product', 'Batch'], - initialOutcome: 'Value', - initialFactors: ['Machine'], - onConfirm: vi.fn(), - onCancel: vi.fn(), -}; - -/** Click a factor toggle by name */ -function clickFactor(name: string) { - // Factor cards render as
(ColumnCard Wrapper for factor role) - const matches = screen.getAllByText(name); - const card = matches.find(el => el.closest('[role="button"]')); - if (!card) throw new Error(`Factor card "${name}" not found`); - fireEvent.click(card.closest('[role="button"]')!); -} - -/** Assert the 4th arg of onConfirm is categories with expected shape */ -function expectCategories( - onConfirm: ReturnType, - expected: Array<{ name: string; factorNames: string[] }> -) { - const categories = onConfirm.mock.calls[0][3] as InvestigationCategory[]; - expect(categories).toBeDefined(); - expect(categories.length).toBe(expected.length); - for (let i = 0; i < expected.length; i++) { - expect(categories[i].name).toBe(expected[i].name); - expect(categories[i].factorNames).toEqual(expected[i].factorNames); - expect(categories[i].id).toBeTruthy(); - expect(categories[i].color).toBeTruthy(); - } -} +// ── Tests ───────────────────────────────────────────────────────────────────── describe('ColumnMapping', () => { - describe('backwards compatibility (availableColumns)', () => { - it('renders all columns in both sections with stub analysis', () => { - render(); - - // All column names should appear (in both Y and X sections) - expect(screen.getAllByText('Value').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('Machine').length).toBeGreaterThanOrEqual(1); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it('defaults to 3 factor limit', () => { - render(); + // ── Rendering ────────────────────────────────────────────────────────────── - expect(screen.getByText('1/3 selected')).toBeTruthy(); + describe('rendering', () => { + it('renders the Map Your Data heading', () => { + render(); + expect(screen.getByTestId('map-your-data-heading')).toBeTruthy(); }); - it('passes categories to onConfirm when values are entered', () => { - const onConfirm = vi.fn(); - render(); - - fireEvent.click(screen.getByText('Set Specification Limits')); - fireEvent.change(screen.getByLabelText('Target specification'), { target: { value: '10' } }); - fireEvent.change(screen.getByLabelText('LSL specification'), { target: { value: '8' } }); - fireEvent.change(screen.getByLabelText('USL specification'), { target: { value: '12' } }); - fireEvent.click(screen.getByText('Start Analysis')); - - expect(onConfirm).toHaveBeenCalledWith( - 'Value', - ['Machine'], - { target: 10, lsl: 8, usl: 12 }, - expect.any(Array), - undefined - ); - expectCategories(onConfirm, [{ name: 'Equipment', factorNames: ['Machine'] }]); + it('renders outcome candidates section', () => { + render(); + expect(screen.getByTestId('outcome-candidates-section')).toBeTruthy(); }); - }); - describe('rich columnAnalysis', () => { - it('renders type badges for columns', () => { + it('renders OutcomeCandidateRow for numeric column', () => { render(); + // OutcomeCandidateRow renders the column name + expect(screen.getByTestId('outcome-candidate-list')).toBeTruthy(); + expect(screen.getAllByText('Value').length).toBeGreaterThanOrEqual(1); + }); - expect(screen.getByTestId('type-badge-Value').textContent).toBe('Numeric'); - expect(screen.getByTestId('type-badge-Machine').textContent).toBe('Categorical'); + it('renders PrimaryScopeDimensionsSelector in setup mode', () => { + render(); + // PrimaryScopeDimensionsSelector renders the heading + expect(screen.getByText('Primary scope dimensions')).toBeTruthy(); }); - it('shows sample values in cards', () => { - render(); + it('does not render PrimaryScopeDimensionsSelector in edit mode', () => { + render(); + expect(screen.queryByText('Primary scope dimensions')).toBeNull(); + }); - // Numeric sample values for Value - expect(screen.getByText(/23\.5/)).toBeTruthy(); - // Categorical sample values for Machine - expect(screen.getByText(/M1, M2, M3/)).toBeTruthy(); + it('renders factor selector in edit mode', () => { + render(); + expect(screen.getByText('Select Factors')).toBeTruthy(); }); + }); - it('shows unique count summary', () => { - render(); + // ── Multi-outcome selection ───────────────────────────────────────────────── - expect(screen.getByText(/847 unique/)).toBeTruthy(); - // Multiple columns have 3 categories, so use getAllByText - expect(screen.getAllByText(/3 categories/).length).toBeGreaterThanOrEqual(1); + describe('multi-outcome selection', () => { + it('starts with initialOutcome pre-selected', () => { + render(); + // The Value checkbox should be checked + const checkboxes = screen.getAllByRole('checkbox'); + const valueCheckbox = checkboxes.find(r => r.getAttribute('aria-label') === 'Value'); + expect(valueCheckbox).toBeTruthy(); + expect((valueCheckbox as HTMLInputElement).checked).toBe(true); }); - it('shows missing warning when missingCount > 0', () => { - const analysisWithMissing = [ - col('Value', 'numeric', { missingCount: 5 }), - col('Machine', 'categorical'), + it('starts with initialOutcomes pre-selected in edit mode', () => { + const initialOutcomes: OutcomeSpec[] = [ + { columnName: 'Value', characteristicType: 'nominalIsBest', target: 24 }, ]; render( - + ); - - expect(screen.getByTestId('missing-warning-Value')).toBeTruthy(); + const checkboxesEdit = screen.getAllByRole('checkbox'); + const valueCheckboxEdit = checkboxesEdit.find(r => r.getAttribute('aria-label') === 'Value'); + expect((valueCheckboxEdit as HTMLInputElement).checked).toBe(true); }); - it('separates numeric columns into Y section and categorical into X section', () => { - render(); - - // Value (numeric) should only appear in Y section initially - // Machine (categorical) should only appear in X section initially - // With type separation, Value should not appear as a factor option by default - const factorCards = screen - .getAllByText('Machine') - .filter(el => el.closest('[role="button"]')); - expect(factorCards.length).toBeGreaterThanOrEqual(1); - }); + it('single-outcome confirm: payload.outcomes has one entry', () => { + const onConfirm = vi.fn(); + render(); - it('shows "Show all columns" toggle', () => { - render(); + fireEvent.click(screen.getByText('Start Analysis')); - // The toggle should be present for both sections - expect(screen.getByTestId('show-all-outcome')).toBeTruthy(); - expect(screen.getByTestId('show-all-factors')).toBeTruthy(); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(1); + expect(payload.outcomes[0].columnName).toBe('Value'); }); - it('reveals all columns when "Show all" is clicked for factors', () => { - render(); + it('multi-outcome confirm: payload.outcomes has multiple entries', () => { + // Two numeric columns: Value + add a second numeric column + const twoNumericAnalysis: ColumnAnalysis[] = [ + col('Weight', 'numeric', { sampleValues: ['10', '11', '12'], uniqueCount: 100 }), + col('Length', 'numeric', { sampleValues: ['5', '6', '7'], uniqueCount: 80 }), + col('Machine', 'categorical', { sampleValues: ['M1', 'M2'], uniqueCount: 2 }), + ]; + const onConfirm = vi.fn(); + render( + + ); - // Value is numeric — should not be in factor section by default - const factorSectionBefore = screen.getByTestId('show-all-factors'); + // Weight is pre-selected; also select Length + const lengthCheckbox = screen + .getAllByRole('checkbox') + .find(r => r.getAttribute('aria-label') === 'Length'); + expect(lengthCheckbox).toBeTruthy(); + fireEvent.click(lengthCheckbox!); - // Click to show all columns in factor section - fireEvent.click(factorSectionBefore); + fireEvent.click(screen.getByText('Start Analysis')); - // Now Value should appear in factor section too (numeric as factor edge case) - const allMatches = screen.getAllByText('Value'); - // Should appear in both outcome and factor sections now - expect(allMatches.length).toBeGreaterThanOrEqual(2); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(2); + const columnNames = payload.outcomes.map(o => o.columnName).sort(); + expect(columnNames).toEqual(['Length', 'Weight']); }); - }); - describe('maxFactors prop', () => { - it('defaults to 3 factor limit', () => { - render(); + it('deselecting an outcome removes it from payload.outcomes', () => { + const onConfirm = vi.fn(); + render(); - expect(screen.getByText('1/3 selected')).toBeTruthy(); - }); + // Deselect Value + const valueCheckboxDesel = screen + .getAllByRole('checkbox') + .find(r => r.getAttribute('aria-label') === 'Value'); + fireEvent.click(valueCheckboxDesel!); - it('respects maxFactors=5', () => { - render(); + // Start Analysis is disabled (no outcomes), but let's verify the state change + // by re-selecting and confirming + fireEvent.click(valueCheckboxDesel!); // re-select + fireEvent.click(screen.getByText('Start Analysis')); - expect(screen.getByText('1/5 selected')).toBeTruthy(); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(1); + expect(payload.outcomes[0].columnName).toBe('Value'); }); - it('respects maxFactors=6', () => { - render(); - - expect(screen.getByText('1/6 selected')).toBeTruthy(); + it('Start Analysis button is disabled when no outcome selected', () => { + render(); + const button = screen.getByText('Start Analysis').closest('button'); + expect(button).toBeTruthy(); + expect(button!.hasAttribute('disabled')).toBe(true); }); - it('allows selecting up to maxFactors columns', () => { - render(); - - clickFactor('Machine'); - clickFactor('Shift'); - clickFactor('Operator'); + it('Start Analysis button is enabled when at least one outcome is selected', () => { + render(); + const button = screen.getByText('Start Analysis').closest('button'); + expect(button!.hasAttribute('disabled')).toBe(false); + }); + }); - expect(screen.getByText('3/3 selected')).toBeTruthy(); + // ── Inline specs per OutcomeCandidateRow ─────────────────────────────────── - // Clicking a 4th shouldn't add it - clickFactor('Line'); - expect(screen.getByText('3/3 selected')).toBeTruthy(); + describe('inline specs per outcome row', () => { + it('inline spec inputs visible when outcome is selected', () => { + render(); + // OutcomeCandidateRow shows spec inputs when selected + expect(screen.getByLabelText('Target')).toBeTruthy(); + expect(screen.getByLabelText('LSL')).toBeTruthy(); + expect(screen.getByLabelText('USL')).toBeTruthy(); + expect(screen.getByLabelText(/Cpk/)).toBeTruthy(); }); - it('allows selecting more than 3 when maxFactors is raised', () => { - render(); + it('spec values flow into payload.outcomes[0] on confirm', () => { + const onConfirm = vi.fn(); + render(); - clickFactor('Machine'); - clickFactor('Shift'); - clickFactor('Operator'); - clickFactor('Line'); + const targetInput = screen.getByLabelText('Target') as HTMLInputElement; + fireEvent.change(targetInput, { target: { value: '24.0' } }); - expect(screen.getByText('4/6 selected')).toBeTruthy(); - }); - }); + const uslInput = screen.getByLabelText('USL') as HTMLInputElement; + fireEvent.change(uslInput, { target: { value: '26.0' } }); - describe('optional specs section', () => { - it('shows collapsed specs section by default', () => { - render(); + fireEvent.click(screen.getByText('Start Analysis')); - expect(screen.getByText('Set Specification Limits')).toBeTruthy(); - expect(screen.queryByTestId('specs-section')).toBeNull(); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes[0].columnName).toBe('Value'); + expect(payload.outcomes[0].target).toBeCloseTo(24.0, 5); + expect(payload.outcomes[0].usl).toBeCloseTo(26.0, 5); }); - it('expands specs section on click', () => { - render(); + it('no sigma-based suggestions: spec inputs are empty by default', () => { + render(); + const uslInput = screen.getByLabelText('USL') as HTMLInputElement; + const lslInput = screen.getByLabelText('LSL') as HTMLInputElement; + // Placeholders say "from customer spec", not a computed value + expect(uslInput.value).toBe(''); + expect(lslInput.value).toBe(''); + }); + }); - fireEvent.click(screen.getByText('Set Specification Limits')); + // ── PrimaryScopeDimensionsSelector ──────────────────────────────────────── - expect(screen.getByTestId('specs-section')).toBeTruthy(); - expect(screen.getByLabelText('Target specification')).toBeTruthy(); - expect(screen.getByLabelText('LSL specification')).toBeTruthy(); - expect(screen.getByLabelText('USL specification')).toBeTruthy(); + describe('PrimaryScopeDimensionsSelector', () => { + it('renders with suggested dimensions checked', () => { + render(); + // Dimensions are shown as checkboxes + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); }); - it('passes categories to onConfirm when specs are entered', () => { + it('selected dimensions appear in payload.primaryScopeDimensions', () => { const onConfirm = vi.fn(); - render(); - - fireEvent.click(screen.getByText('Set Specification Limits')); - fireEvent.change(screen.getByLabelText('Target specification'), { target: { value: '10' } }); - fireEvent.change(screen.getByLabelText('LSL specification'), { target: { value: '8' } }); - fireEvent.change(screen.getByLabelText('USL specification'), { target: { value: '12' } }); - fireEvent.click(screen.getByText('Start Analysis')); - - expect(onConfirm).toHaveBeenCalledWith( - 'Value', - ['Machine'], - { target: 10, lsl: 8, usl: 12 }, - expect.any(Array), - undefined + render( + ); - expectCategories(onConfirm, [{ name: 'Equipment', factorNames: ['Machine'] }]); - }); - it('passes categories when no specs entered', () => { - const onConfirm = vi.fn(); - render(); + // Manually check Machine checkbox + const machineCheckbox = screen.getAllByRole('checkbox').find(c => { + const label = c.closest('label'); + return label?.textContent?.includes('Machine'); + }); + if (machineCheckbox && !(machineCheckbox as HTMLInputElement).checked) { + fireEvent.click(machineCheckbox); + } fireEvent.click(screen.getByText('Start Analysis')); - expect(onConfirm).toHaveBeenCalledWith( - 'Value', - ['Machine'], - undefined, - expect.any(Array), - undefined - ); - expectCategories(onConfirm, [{ name: 'Equipment', factorNames: ['Machine'] }]); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.primaryScopeDimensions).toContain('Machine'); }); - it('passes partial specs when only some values entered', () => { + it('initialPrimaryScopeDimensions seeds the selector', () => { const onConfirm = vi.fn(); - render(); + render( + + ); - fireEvent.click(screen.getByText('Set Specification Limits')); - fireEvent.change(screen.getByLabelText('Target specification'), { - target: { value: '10.5' }, + // Shift checkbox should be checked + const shiftCheckbox = screen.getAllByRole('checkbox').find(c => { + const label = c.closest('label'); + return label?.textContent?.includes('Shift'); }); - fireEvent.click(screen.getByText('Start Analysis')); - - expect(onConfirm).toHaveBeenCalledWith( - 'Value', - ['Machine'], - { target: 10.5 }, - expect.any(Array), - undefined - ); - expectCategories(onConfirm, [{ name: 'Equipment', factorNames: ['Machine'] }]); + expect(shiftCheckbox).toBeTruthy(); + expect((shiftCheckbox as HTMLInputElement).checked).toBe(true); }); }); - describe('investigation categories', () => { - it('renders CategoryBadge with dynamic category name', () => { - render(); + // ── OutcomeNoMatchBanner ─────────────────────────────────────────────────── - // Machine should infer "Equipment" category - const badge = screen.getByTestId('category-badge'); - expect(badge.textContent).toContain('Equipment'); + describe('OutcomeNoMatchBanner', () => { + it('surfaces when all candidates score below threshold', () => { + // All-text (non-numeric) columns should score below default threshold + const allTextAnalysis: ColumnAnalysis[] = [ + col('foo', 'text', { uniqueCount: 5 }), + col('bar', 'text', { uniqueCount: 3 }), + ]; + render( + + ); + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.getByText(/No clear outcome match/)).toBeTruthy(); + }); + + it('does not surface when numeric candidates are present (score >= threshold)', () => { + render(); + expect(screen.queryByRole('alert')).toBeNull(); }); - it('groups multiple factors under same inferred category', () => { + it('Skip CTA clears selected outcomes', () => { + const allTextAnalysis: ColumnAnalysis[] = [ + col('foo', 'text', { uniqueCount: 5 }), + col('bar', 'text', { uniqueCount: 3 }), + ]; const onConfirm = vi.fn(); - // Machine and Line both match "equipment" keywords render( - + + ); + // Banner is present + expect(screen.getByRole('alert')).toBeTruthy(); + // Click Skip + fireEvent.click(screen.getByRole('button', { name: /Skip outcome/i })); + // After skip, Start Analysis should still be reachable (no outcome = disabled) + // — confirm by checking the payload will have zero outcomes after a forced click + // (we test the state was cleared, not the button disabled state) + // Manually enable: the Start Analysis button is disabled when no outcome selected, + // so we verify the internal state by checking payload.outcomes is empty on a force-submit. + // Force-click the disabled button to fire the confirm handler anyway: + const btn = screen.getByText('Start Analysis').closest('button')!; + // button is disabled after skip — this confirms onSkip cleared outcomes + expect(btn.hasAttribute('disabled')).toBe(true); + }); + + it('expectedOutcomeNote is included in payload after onExpectedChange', () => { + const allTextAnalysis: ColumnAnalysis[] = [ + col('foo', 'text', { uniqueCount: 5 }), + col('bar', 'text', { uniqueCount: 3 }), + ]; + const onConfirm = vi.fn(); + render( + ); + // Banner is present + expect(screen.getByRole('alert')).toBeTruthy(); + // Type in the expected note + const noteInput = screen.getByPlaceholderText(/e\.g\. reject_rate/i) as HTMLInputElement; + fireEvent.change(noteInput, { target: { value: 'reject_rate' } }); + // Confirm (foo is pre-selected by initialOutcome) fireEvent.click(screen.getByText('Start Analysis')); - // Both should be grouped under "Equipment" category - expectCategories(onConfirm, [{ name: 'Equipment', factorNames: ['Machine', 'Line'] }]); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.expectedOutcomeNote).toBe('reject_rate'); }); + }); - it('creates separate categories for different inferred roles', () => { - const onConfirm = vi.fn(); - // Machine → Equipment, Shift → Temporal, Operator → People + // ── mode='edit' round-trip ──────────────────────────────────────────────── + + describe("mode='edit'", () => { + it('preloads initialOutcomes', () => { + const initialOutcomes: OutcomeSpec[] = [ + { + columnName: 'Value', + characteristicType: 'nominalIsBest', + target: 24, + lsl: 22, + usl: 26, + }, + ]; render( ); + const valueCheckboxPreload = screen + .getAllByRole('checkbox') + .find(r => r.getAttribute('aria-label') === 'Value'); + expect((valueCheckboxPreload as HTMLInputElement).checked).toBe(true); - fireEvent.click(screen.getByText('Start Analysis')); + // Inline specs should be pre-filled from initialOutcomes + const targetInput = screen.getByLabelText('Target') as HTMLInputElement; + expect(targetInput.value).toBe('24'); + }); - const categories = onConfirm.mock.calls[0][3] as InvestigationCategory[]; - expect(categories.length).toBe(3); - const names = categories.map(c => c.name).sort(); - expect(names).toEqual(['Equipment', 'People', 'Temporal']); + it('Save verb in edit mode footer', () => { + render(); + expect(screen.getByText('Apply Changes')).toBeTruthy(); }); - it('preserves initialCategories when passed', () => { + it('edit confirm updates outcomes and factors in payload', () => { const onConfirm = vi.fn(); - const existingCategories: InvestigationCategory[] = [ - { id: 'cat-1', name: 'Machinery', factorNames: ['Machine'], color: '#ff0000' }, + const initialOutcomes: OutcomeSpec[] = [ + { columnName: 'Value', characteristicType: 'nominalIsBest' }, ]; render( ); - fireEvent.click(screen.getByText('Start Analysis')); + fireEvent.click(screen.getByText('Apply Changes')); - const categories = onConfirm.mock.calls[0][3] as InvestigationCategory[]; - expect(categories.length).toBe(1); - expect(categories[0].name).toBe('Machinery'); - expect(categories[0].id).toBe('cat-1'); // preserved - expect(categories[0].color).toBe('#ff0000'); // preserved + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(1); + expect(payload.outcomes[0].columnName).toBe('Value'); }); - it('passes undefined categories when no factors have inferred categories', () => { - const onConfirm = vi.fn(); - // "Product" doesn't match any keyword group - render(); - - fireEvent.click(screen.getByText('Start Analysis')); - - expect(onConfirm.mock.calls[0][3]).toBeUndefined(); + it('shows SpecsSection in edit mode when hideSpecs=false', () => { + render(); + expect(screen.getByText('Set Specification Limits')).toBeTruthy(); }); - it('dismisses category badge on X click', () => { - render(); + it('edit-mode roundtrip preserves all initialOutcomes (multi-outcome)', () => { + // Regression test for Critical #1: editing an existing Hub with 2 outcomes + // must preload BOTH rows and emit BOTH in the confirm payload. + const twoNumericAnalysis = [ + col('A', 'numeric', { sampleValues: ['1', '2', '3'], uniqueCount: 100 }), + col('B', 'numeric', { sampleValues: ['10', '20', '30'], uniqueCount: 80 }), + col('Machine', 'categorical', { sampleValues: ['M1', 'M2'], uniqueCount: 2 }), + ]; + const initialOutcomes: OutcomeSpec[] = [ + { columnName: 'A', characteristicType: 'nominalIsBest', target: 2 }, + { columnName: 'B', characteristicType: 'largerIsBetter' }, + ]; + const onConfirm = vi.fn(); + render( + + ); - expect(screen.getByTestId('category-badge')).toBeTruthy(); + // Both A and B checkboxes should be pre-checked + const checkboxes = screen.getAllByRole('checkbox'); + const checkboxA = checkboxes.find(r => r.getAttribute('aria-label') === 'A'); + const checkboxB = checkboxes.find(r => r.getAttribute('aria-label') === 'B'); + expect((checkboxA as HTMLInputElement).checked).toBe(true); + expect((checkboxB as HTMLInputElement).checked).toBe(true); - // Dismiss the badge - fireEvent.click(screen.getByLabelText('Dismiss Equipment category')); + fireEvent.click(screen.getByText('Apply Changes')); - expect(screen.queryByTestId('category-badge')).toBeNull(); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(2); + const columnNames = payload.outcomes.map(o => o.columnName).sort(); + expect(columnNames).toEqual(['A', 'B']); }); }); - describe('column renaming', () => { - it('calls onColumnRename when rename is completed', () => { - const onColumnRename = vi.fn(); - render(); + // ── Hub-shaped payload shape ─────────────────────────────────────────────── - // Find and click the rename button for Machine (there may be 2 — one in each section if showing all) - const renameBtns = screen.getAllByLabelText('Rename Machine'); - fireEvent.click(renameBtns[0]); + describe('Hub-shaped payload (no legacy compat fields)', () => { + it('payload has no "outcome" or "factors" fields', () => { + const onConfirm = vi.fn(); + render(); - // Should show input — find the input element specifically - const input = screen - .getAllByLabelText('Rename Machine') - .find(el => el.tagName === 'INPUT') as HTMLInputElement; - expect(input).toBeTruthy(); - fireEvent.change(input, { target: { value: 'Equipment' } }); - fireEvent.blur(input); + fireEvent.click(screen.getByText('Start Analysis')); - expect(onColumnRename).toHaveBeenCalledWith('Machine', 'Equipment'); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + // New shape: no legacy single-outcome or factor fields + expect('outcome' in payload).toBe(false); + expect('factors' in payload).toBe(false); + expect('specs' in payload).toBe(false); + // Hub-shaped fields present + expect(Array.isArray(payload.outcomes)).toBe(true); + expect(Array.isArray(payload.primaryScopeDimensions)).toBe(true); }); - it('shows alias with original name subtitle', () => { - render(); + it('outcomes[] is the canonical field for selected outcomes', () => { + const onConfirm = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Start Analysis')); - expect(screen.getAllByText('Equipment').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('(Machine)').length).toBeGreaterThanOrEqual(1); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.outcomes).toHaveLength(1); + expect(payload.outcomes[0].columnName).toBe('Value'); }); }); + // ── Analysis brief ───────────────────────────────────────────────────────── + describe('analysis brief', () => { it('shows issue statement field in non-brief mode (PWA)', () => { render(); - expect(screen.getByTestId('issue-statement-simple')).toBeTruthy(); expect(screen.getByPlaceholderText(/What are you investigating/)).toBeTruthy(); }); it('shows full brief section when showBrief=true', () => { render(); - expect(screen.getByTestId('analysis-brief')).toBeTruthy(); expect(screen.queryByTestId('issue-statement-simple')).toBeNull(); }); @@ -504,7 +614,7 @@ describe('ColumnMapping', () => { expect(screen.getByTestId('brief-target-metric')).toBeTruthy(); }); - it('passes brief data through onConfirm', () => { + it('passes brief data through payload.brief', () => { const onConfirm = vi.fn(); render(); @@ -514,9 +624,9 @@ describe('ColumnMapping', () => { }); fireEvent.click(screen.getByText('Start Analysis')); - const brief = onConfirm.mock.calls[0][4]; - expect(brief).toBeDefined(); - expect(brief.issueStatement).toBe('Cpk is below target'); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.brief).toBeDefined(); + expect(payload.brief!.issueStatement).toBe('Cpk is below target'); }); it('passes undefined brief when no fields filled', () => { @@ -525,8 +635,8 @@ describe('ColumnMapping', () => { fireEvent.click(screen.getByText('Start Analysis')); - const brief = onConfirm.mock.calls[0][4]; - expect(brief).toBeUndefined(); + const payload: ColumnMappingConfirmPayload = onConfirm.mock.calls[0][0]; + expect(payload.brief).toBeUndefined(); }); it('pre-fills issue statement from initialIssueStatement', () => { @@ -545,6 +655,8 @@ describe('ColumnMapping', () => { }); }); + // ── Data preview table ───────────────────────────────────────────────────── + describe('data preview table', () => { const previewRows = [ { Value: 23.5, Machine: 'M1', Shift: 'Morning' }, @@ -554,28 +666,48 @@ describe('ColumnMapping', () => { it('shows preview toggle when previewRows are provided', () => { render(); - expect(screen.getByTestId('preview-toggle')).toBeTruthy(); expect(screen.getByText(/120 rows/)).toBeTruthy(); }); it('does not show preview table by default (collapsed)', () => { render(); - expect(screen.queryByTestId('preview-table')).toBeNull(); }); it('expands preview table on click', () => { render(); - fireEvent.click(screen.getByTestId('preview-toggle')); expect(screen.getByTestId('preview-table')).toBeTruthy(); }); it('does not render preview when no previewRows', () => { render(); - expect(screen.queryByTestId('preview-toggle')).toBeNull(); }); }); + + // ── goalContext biasing (D4) ─────────────────────────────────────────────── + + describe('goalContext biasing', () => { + it('column matching goal context words appears in candidate list', () => { + const weightFocusAnalysis: ColumnAnalysis[] = [ + col('weight_g', 'numeric', { sampleValues: ['10', '11', '12'], uniqueCount: 100 }), + col('unrelated', 'numeric', { sampleValues: ['1', '2', '3'], uniqueCount: 50 }), + ]; + render( + + ); + // Both candidates render; weight_g gets higher score + expect(screen.getByTestId('outcome-candidate-list')).toBeTruthy(); + expect(screen.getAllByText(/weight_g/).length).toBeGreaterThanOrEqual(1); + }); + }); }); diff --git a/packages/ui/src/components/ColumnMapping/index.tsx b/packages/ui/src/components/ColumnMapping/index.tsx index 29a7f6516..bff205efc 100644 --- a/packages/ui/src/components/ColumnMapping/index.tsx +++ b/packages/ui/src/components/ColumnMapping/index.tsx @@ -1,13 +1,14 @@ /** - * ColumnMapping - Data-rich column mapping UI for data setup + * ColumnMapping — Hub-level data mapper for Stage 3 of Mode B. * - * Allows users to: - * - Preview first rows of data in a collapsible table - * - Select outcome (Y) column with type-filtered cards - * - Select factor (X) columns with type-filtered cards - * - Rename columns (writes to columnAliases) - * - Optionally upload separate Pareto file - * - Shows data quality validation results + * Refactored in slice 2 to be the canonical Hub-level mapper: + * - Multi-outcome selection via OutcomeCandidateRow (each row is independently toggled) + * - Inline specs per selected outcome (within the row, no separate SpecsSection for setup) + * - PrimaryScopeDimensionsSelector sub-step for scope dimension confirmation + * - OutcomeNoMatchBanner when all candidates score below threshold + * - onConfirm emits ColumnMappingConfirmPayload (Hub-shaped, no legacy 3-arg form) + * + * In mode='edit': pre-loads existing Hub outcomes + primaryScopeDimensions. */ import React, { useState, useMemo, useCallback } from 'react'; @@ -21,9 +22,14 @@ import SpecsSection from './SpecsSection'; import ParetoUpload from './ParetoUpload'; import TimeExtractionPanel from './TimeExtractionPanel'; import { StackSection } from './StackSection'; +import { OutcomeCandidateRow } from '../OutcomeCandidateRow/OutcomeCandidateRow'; +import type { OutcomeCandidate } from '../OutcomeCandidateRow/OutcomeCandidateRow'; +import { PrimaryScopeDimensionsSelector } from '../PrimaryScopeDimensionsSelector/PrimaryScopeDimensionsSelector'; +import { OutcomeNoMatchBanner } from '../OutcomeNoMatchBanner/OutcomeNoMatchBanner'; +import type { OutcomeSpec } from '@variscout/core/processHub'; import type { ColumnAnalysis, - CharacteristicType, + CharacteristicType as LegacyCharacteristicType, DataQualityReport, DataRow, TimeExtractionConfig, @@ -31,6 +37,7 @@ import type { TargetMetric, StackConfig, StackSuggestion, + ParetoMode, } from '@variscout/core'; import { inferCategoryName, @@ -38,6 +45,7 @@ import { createInvestigationCategory, CATEGORY_COLORS, } from '@variscout/core'; +import { suggestPrimaryDimensions } from '@variscout/core'; /** Analysis brief data for investigation context (optional) */ export interface AnalysisBrief { @@ -53,6 +61,40 @@ export interface AnalysisBrief { }; } +/** + * Hub-shaped onConfirm contract. + * All call sites use this shape; legacy (outcome, factors, specs) fields are gone. + */ +export interface ColumnMappingConfirmPayload { + /** Multi-outcome selection. */ + outcomes: OutcomeSpec[]; + /** Columns the analyst will slice analysis by most often. */ + primaryScopeDimensions: string[]; + /** Stack config from wide-form detection (unchanged from slice-1). */ + stack?: StackConfig | null; + /** Time extraction config (unchanged from slice-1). */ + timeExtraction?: TimeExtractionConfig; + /** Pareto mode (unchanged from slice-1). */ + paretoMode?: ParetoMode; + /** Separate Pareto filename when paretoMode='separate' (unchanged from slice-1). */ + separateParetoFilename?: string | null; + /** + * Investigation categories inferred from factor selection (edit mode). + * Used by the downstream investigation store for category grouping. + */ + categories?: InvestigationCategory[]; + /** Analysis brief from Azure full-brief fields. */ + brief?: AnalysisBrief; + /** + * Free-text note from the OutcomeNoMatchBanner "I expected the outcome to be" input. + * Present when the banner surfaced and the analyst typed a note. + * Carry-forward: ProcessHub has no field for this yet — downstream handlers + * may attach it to hub metadata when the field lands (see decision-log entry + * "Slice 2 — OutcomeNoMatchBanner expectedOutcomeNote carry-forward"). + */ + expectedOutcomeNote?: string; +} + export interface ColumnMappingProps { /** Rich column metadata from detectColumns(). Preferred over availableColumns. */ columnAnalysis?: ColumnAnalysis[]; @@ -66,21 +108,23 @@ export interface ColumnMappingProps { columnAliases?: Record; /** Callback when user renames a column */ onColumnRename?: (originalName: string, alias: string) => void; + /** + * Legacy initial outcome column name. + * Used to seed the initial selected outcome when no initialOutcomes provided. + */ initialOutcome: string | null; + /** + * Legacy initial factor columns. + * Used to seed initial scope dimensions when no initialPrimaryScopeDimensions provided. + */ initialFactors: string[]; + /** Initial outcomes (Hub-level, for mode='edit' round-trip). */ + initialOutcomes?: OutcomeSpec[]; + /** Initial primary scope dimensions (Hub-level, for mode='edit' round-trip). */ + initialPrimaryScopeDimensions?: string[]; datasetName?: string; - onConfirm: ( - outcome: string, - factors: string[], - specs?: { - target?: number; - lsl?: number; - usl?: number; - characteristicType?: CharacteristicType; - }, - categories?: InvestigationCategory[], - brief?: AnalysisBrief - ) => void; + /** New Hub-shaped onConfirm contract. */ + onConfirm: (payload: ColumnMappingConfirmPayload) => void; onCancel: () => void; onBack?: () => void; /** Pre-existing investigation categories (from project load / previous mapping) */ @@ -116,6 +160,13 @@ export interface ColumnMappingProps { rowLimit?: number; /** Hide specification limits section (e.g., defect mode where Cpk is not applicable) */ hideSpecs?: boolean; + /** + * Goal narrative from Stage 1, used for outcome detection biasing. + * Keywords extracted deterministically (D4) to bias candidate ranking. + */ + goalContext?: string; + /** Score threshold below which OutcomeNoMatchBanner is shown (default: 0.1) */ + noMatchThreshold?: number; } /** @@ -132,6 +183,90 @@ function buildStubAnalysis(names: string[]): ColumnAnalysis[] { })); } +/** Threshold for outcome candidate match score below which the banner surfaces. */ +const DEFAULT_NO_MATCH_THRESHOLD = 0.1; + +/** + * Build OutcomeCandidate list from ColumnAnalysis, biased by goal context keywords. + * Uses deterministic scoring (D4): keyword overlap in column name is additive on top + * of existing type-based ranking. No σ-based suggestions (spec §3.3). + */ +function buildOutcomeCandidates( + columns: ColumnAnalysis[], + goalContext?: string +): OutcomeCandidate[] { + // Extract goal keywords deterministically (D4) + const goalKeywords = goalContext + ? goalContext + .toLowerCase() + .split(/\s+/) + .filter(w => w.length > 2) + : []; + + return columns.map(col => { + // Base score: numeric columns score higher as outcome candidates + let score = col.type === 'numeric' ? 0.5 : 0.05; + + // Bonus for column names matching typical outcome heuristics + const lower = col.name.toLowerCase(); + const outcomeKeywords = [ + 'weight', + 'height', + 'length', + 'width', + 'temp', + 'temperature', + 'pressure', + 'yield', + 'rate', + 'count', + 'defect', + 'time', + 'duration', + 'measure', + 'value', + 'output', + 'result', + ]; + if (outcomeKeywords.some(k => lower.includes(k))) { + score += 0.2; + } + + // Goal context bias (D4): keyword match is additive + let goalKeywordMatch: string | undefined; + for (const kw of goalKeywords) { + const nameParts = lower.split(/[_\s-]+/); + if (nameParts.some(part => part === kw || part.startsWith(kw) || kw.startsWith(part))) { + score += 0.3; + goalKeywordMatch = kw; + break; + } + } + + // Parse numeric values from sampleValues + const values: number[] = col.sampleValues + .map(v => parseFloat(String(v))) + .filter(v => Number.isFinite(v)); + + // Determine characteristic type: default to nominalIsBest + const characteristicType: OutcomeSpec['characteristicType'] = 'nominalIsBest'; + + return { + columnName: col.name, + type: col.type === 'numeric' ? ('continuous' as const) : ('discrete' as const), + characteristicType, + values, + matchScore: Math.min(1, score), + goalKeywordMatch, + qualityReport: { + validCount: (col.uniqueCount || 0) + values.length, // approximate + invalidCount: 0, + missingCount: col.missingCount, + }, + }; + }); +} + export const ColumnMapping: React.FC = ({ columnAnalysis: columnAnalysisProp, availableColumns, @@ -141,6 +276,8 @@ export const ColumnMapping: React.FC = ({ onColumnRename, initialOutcome, initialFactors, + initialOutcomes, + initialPrimaryScopeDimensions, datasetName = 'Uploaded Dataset', onConfirm, onCancel, @@ -165,21 +302,13 @@ export const ColumnMapping: React.FC = ({ onStackConfigChange, rowLimit = 50000, hideSpecs = false, + goalContext, + noMatchThreshold = DEFAULT_NO_MATCH_THRESHOLD, }) => { const { t } = useTranslation(); const isPhone = useIsMobile(BREAKPOINTS.phone); - const [outcome, setOutcome] = useState(initialOutcome || ''); - const [factors, setFactors] = useState(initialFactors || []); - const [showAllOutcome, setShowAllOutcome] = useState(false); - const [showAllFactors, setShowAllFactors] = useState(false); - const [dismissedRoles, setDismissedRoles] = useState>(new Set()); - // Stack config state (internal — syncs to parent via onStackConfigChange). - // Auto-enable is intentionally OFF: the heuristic flagged 21/33 cols as - // stackable on a 35-col wide-form sensor dataset where each column was a - // distinct measurement, blocking Start Analysis with "Name the stacked - // columns to continue." Stack Columns remains opt-in via the toggle, with - // the suggestion still surfaced as a hint (`suggestedStack`). + // ── Stack config ───────────────────────────────────────────────────────── const [stackConfig, setStackConfig] = useState(() => { return initialStackConfig ?? null; }); @@ -192,36 +321,154 @@ export const ColumnMapping: React.FC = ({ [onStackConfigChange] ); - // Stack validation: both names required when stack is enabled const isStackValid = !stackConfig || (!!stackConfig.measureName.trim() && !!stackConfig.labelName.trim() && stackConfig.columnsToStack.length > 0); - // Brief fields state - const [issueStatement, setIssueStatement] = useState(initialIssueStatement || ''); - const [briefQuestions, setBriefQuestions] = useState< - Array<{ text: string; factor: string; level: string }> - >([]); - const [briefExpanded, setBriefExpanded] = useState(!!initialIssueStatement); - const [targetMetric, setTargetMetric] = useState(''); - const [targetDirection, setTargetDirection] = useState<'minimize' | 'maximize' | 'target'>( - 'minimize' + // ── Resolve column analysis ─────────────────────────────────────────────── + const columns = useMemo(() => { + if (columnAnalysisProp && columnAnalysisProp.length > 0) return columnAnalysisProp; + if (availableColumns && availableColumns.length > 0) return buildStubAnalysis(availableColumns); + return []; + }, [columnAnalysisProp, availableColumns]); + + const hasRichData = !!(columnAnalysisProp && columnAnalysisProp.length > 0); + const numericColumns = useMemo(() => columns.filter(c => c.type === 'numeric'), [columns]); + const nonNumericColumns = useMemo(() => columns.filter(c => c.type !== 'numeric'), [columns]); + + // ── Outcome candidates (Hub-level multi-select) ─────────────────────────── + const outcomeCandidates = useMemo( + () => buildOutcomeCandidates(columns, goalContext), + [columns, goalContext] ); - const [targetValue, setTargetValue] = useState(''); + /** + * Map of columnName → selected OutcomeSpec (partial — user fills in specs inline). + * Seeded from initialOutcomes (edit mode) or initialOutcome (legacy setup mode). + */ + const [selectedOutcomeSpecs, setSelectedOutcomeSpecs] = useState< + Record> + >(() => { + if (initialOutcomes && initialOutcomes.length > 0) { + return Object.fromEntries(initialOutcomes.map(o => [o.columnName, o])); + } + if (initialOutcome) { + const candidate = outcomeCandidates.find(c => c.columnName === initialOutcome); + return { + [initialOutcome]: { + columnName: initialOutcome, + characteristicType: candidate?.characteristicType ?? 'nominalIsBest', + }, + }; + } + return {}; + }); + + const selectedOutcomeNames = useMemo( + () => new Set(Object.keys(selectedOutcomeSpecs)), + [selectedOutcomeSpecs] + ); + + const handleToggleOutcome = useCallback((columnName: string, candidate: OutcomeCandidate) => { + setSelectedOutcomeSpecs(prev => { + if (columnName in prev) { + // Deselect + const { [columnName]: _removed, ...rest } = prev; + return rest; + } + // Select — seed with characteristicType from candidate + return { + ...prev, + [columnName]: { + columnName, + characteristicType: candidate.characteristicType, + }, + }; + }); + }, []); + + const handleSpecsChange = useCallback((columnName: string, specs: Partial) => { + setSelectedOutcomeSpecs(prev => ({ + ...prev, + [columnName]: { ...prev[columnName], ...specs, columnName }, + })); + }, []); + + // Determine if no-match banner should surface + const allCandidatesBelowThreshold = useMemo( + () => + outcomeCandidates.length > 0 && outcomeCandidates.every(c => c.matchScore < noMatchThreshold), + [outcomeCandidates, noMatchThreshold] + ); + + // ── Primary scope dimensions ─────────────────────────────────────────────── + const dimensionCandidates = useMemo( + () => + nonNumericColumns.map(c => ({ + name: c.name, + uniqueCount: c.uniqueCount || c.sampleValues.length, + })), + [nonNumericColumns] + ); + + const suggestedDimensions = useMemo( + () => suggestPrimaryDimensions(dimensionCandidates), + [dimensionCandidates] + ); + + const [primaryScopeDimensions, setPrimaryScopeDimensions] = useState(() => { + if (initialPrimaryScopeDimensions && initialPrimaryScopeDimensions.length > 0) { + return initialPrimaryScopeDimensions; + } + // In legacy setup mode, seed from initialFactors + if (initialFactors && initialFactors.length > 0) { + return initialFactors; + } + // Auto-suggest on first render (setup mode) + return []; + }); + + // ── OutcomeNoMatchBanner state ──────────────────────────────────────────── + const [expectedOutcomeNote, setExpectedOutcomeNote] = useState(''); + + // ── Legacy factor selection (kept for factors → categories inference) ───── + const [factors, setFactors] = useState(initialFactors || []); + const [showAllOutcome, setShowAllOutcome] = useState(false); + const [showAllFactors, setShowAllFactors] = useState(false); + const [dismissedRoles, setDismissedRoles] = useState>(new Set()); + + const outcomeColumns = hasRichData && !showAllOutcome ? numericColumns : columns; + const factorColumns = hasRichData && !showAllFactors ? nonNumericColumns : columns; + + // Derived legacy outcome string for factors section exclusion logic + const legacyOutcome = useMemo( + () => (selectedOutcomeNames.size > 0 ? [...selectedOutcomeNames][0] : (initialOutcome ?? '')), + [selectedOutcomeNames, initialOutcome] + ); + + const toggleFactor = (col: string) => { + if (selectedOutcomeNames.has(col)) return; + if (factors.includes(col)) { + setFactors(factors.filter(f => f !== col)); + } else { + if (factors.length < maxFactors) { + setFactors([...factors, col]); + } + } + }; + + // ── Category inference (kept for downstream investigation store compat) ─── const initialCategories = useMemo(() => { if (initialCategoriesProp && initialCategoriesProp.length > 0) return initialCategoriesProp; return []; }, [initialCategoriesProp]); - // Infer category names for selected factors const inferredCategories = useMemo(() => { const result: Record = {}; for (const factor of factors) { if (dismissedRoles.has(factor)) continue; - // Check initialCategories first (persisted from previous session) const existingCat = initialCategories.find(c => c.factorNames.includes(factor)); if (existingCat) { result[factor] = { @@ -239,15 +486,12 @@ export const ColumnMapping: React.FC = ({ return result; }, [factors, dismissedRoles, initialCategories]); - // Compute color map for unique category names const categoryColorMap = useMemo(() => { const uniqueNames = [...new Set(Object.values(inferredCategories).map(c => c.categoryName))]; const colorMap: Record = {}; - // Preserve colors from initialCategories first for (const cat of initialCategories) { if (cat.color) colorMap[cat.name] = cat.color; } - // Assign colors to remaining unique names let colorIndex = initialCategories.length; for (const name of uniqueNames) { if (!colorMap[name]) { @@ -258,7 +502,18 @@ export const ColumnMapping: React.FC = ({ return colorMap; }, [inferredCategories, initialCategories]); - // Brief question helpers + // ── Analysis brief state ────────────────────────────────────────────────── + const [issueStatement, setIssueStatement] = useState(initialIssueStatement || ''); + const [briefQuestions, setBriefQuestions] = useState< + Array<{ text: string; factor: string; level: string }> + >([]); + const [briefExpanded, setBriefExpanded] = useState(!!initialIssueStatement); + const [targetMetric, setTargetMetric] = useState(''); + const [targetDirection, setTargetDirection] = useState<'minimize' | 'maximize' | 'target'>( + 'minimize' + ); + const [targetValue, setTargetValue] = useState(''); + const addBriefQuestion = useCallback(() => { setBriefQuestions(prev => [...prev, { text: '', factor: '', level: '' }]); }, []); @@ -278,24 +533,6 @@ export const ColumnMapping: React.FC = ({ setBriefQuestions(prev => prev.filter((_, i) => i !== index)); }, []); - // Optional specs state - const [specsExpanded, setSpecsExpanded] = useState(false); - const [specTarget, setSpecTarget] = useState(''); - const [specLsl, setSpecLsl] = useState(''); - const [specUsl, setSpecUsl] = useState(''); - const [specCharType, setSpecCharType] = useState(null); - - // Resolve column analysis: prefer rich data, fall back to stubs from names - const columns = useMemo(() => { - if (columnAnalysisProp && columnAnalysisProp.length > 0) return columnAnalysisProp; - if (availableColumns && availableColumns.length > 0) return buildStubAnalysis(availableColumns); - return []; - }, [columnAnalysisProp, availableColumns]); - - // Has rich metadata? - const hasRichData = !!(columnAnalysisProp && columnAnalysisProp.length > 0); - - // Get unique levels for a factor column from columnAnalysis const getFactorLevels = useCallback( (factorName: string): string[] => { const col = columns.find(c => c.name === factorName); @@ -305,34 +542,108 @@ export const ColumnMapping: React.FC = ({ [columns] ); - // Type-separated columns - const numericColumns = useMemo(() => columns.filter(c => c.type === 'numeric'), [columns]); - const nonNumericColumns = useMemo(() => columns.filter(c => c.type !== 'numeric'), [columns]); + // ── Standalone specs section (edit mode only, or when hideSpecs=false & no candidates) ── + // In setup mode, specs are now inline per OutcomeCandidateRow. + // In edit mode, the standalone SpecsSection remains for single-outcome compat. + const [specsExpanded, setSpecsExpanded] = useState(false); + const [specTarget, setSpecTarget] = useState(''); + const [specLsl, setSpecLsl] = useState(''); + const [specUsl, setSpecUsl] = useState(''); + const [specCharType, setSpecCharType] = useState(null); - // Columns shown in each section - const outcomeColumns = hasRichData && !showAllOutcome ? numericColumns : columns; - const factorColumns = hasRichData && !showAllFactors ? nonNumericColumns : columns; + // ── Validation ──────────────────────────────────────────────────────────── + const hasAtLeastOneOutcome = selectedOutcomeNames.size > 0; + const isValid = hasAtLeastOneOutcome && isStackValid; - const toggleFactor = (col: string) => { - if (col === outcome) return; - if (factors.includes(col)) { - setFactors(factors.filter(f => f !== col)); - } else { - if (factors.length < maxFactors) { - setFactors([...factors, col]); + // ── Confirm handler ─────────────────────────────────────────────────────── + const handleConfirm = useCallback(() => { + // Build OutcomeSpec[] from selected specs + const outcomes: OutcomeSpec[] = Object.entries(selectedOutcomeSpecs).map( + ([columnName, partial]) => ({ + columnName, + characteristicType: partial.characteristicType ?? 'nominalIsBest', + ...(partial.target !== undefined ? { target: partial.target } : {}), + ...(partial.lsl !== undefined ? { lsl: partial.lsl } : {}), + ...(partial.usl !== undefined ? { usl: partial.usl } : {}), + ...(partial.cpkTarget !== undefined ? { cpkTarget: partial.cpkTarget } : {}), + }) + ); + + // Build legacy categories from inferred + const catGroups = new Map(); + for (const [factorName, { categoryName }] of Object.entries(inferredCategories)) { + const group = catGroups.get(categoryName) || []; + group.push(factorName); + catGroups.set(categoryName, group); + } + let categories: InvestigationCategory[] | undefined; + if (catGroups.size > 0) { + categories = []; + let idx = 0; + for (const [name, factorNames] of catGroups) { + const existing = initialCategories.find(c => c.name === name); + if (existing) { + categories.push({ ...existing, factorNames }); + } else { + categories.push(createInvestigationCategory(name, factorNames, idx)); + } + idx++; } } - }; - const handleOutcomeChange = (col: string) => { - setOutcome(col); - if (factors.includes(col)) { - setFactors(factors.filter(f => f !== col)); + // Build analysis brief + const brief: AnalysisBrief = {}; + if (issueStatement.trim()) brief.issueStatement = issueStatement.trim(); + const validQuestions = briefQuestions.filter(h => h.text.trim()); + if (validQuestions.length > 0) { + brief.questions = validQuestions.map(h => ({ + text: h.text.trim(), + ...(h.factor ? { factor: h.factor } : {}), + ...(h.level ? { level: h.level } : {}), + })); } - }; + const tv = parseFloat(targetValue); + if (targetMetric && !isNaN(tv)) { + brief.target = { + metric: targetMetric as TargetMetric, + direction: targetDirection, + value: tv, + }; + } + const hasBrief = brief.issueStatement || brief.questions || brief.target; - const isValid = !!outcome && isStackValid; + onConfirm({ + outcomes, + primaryScopeDimensions, + // Stack/time/pareto pass-through + stack: stackConfig, + // timeExtraction is managed by parent via onTimeExtractionChange; not stored here + paretoMode: paretoMode as ColumnMappingConfirmPayload['paretoMode'], + separateParetoFilename: separateParetoFilename ?? null, + // Investigation categories (edit mode — downstream store compat) + categories: categories ?? undefined, + brief: hasBrief ? brief : undefined, + // OutcomeNoMatchBanner note (carry-forward: no ProcessHub field yet) + expectedOutcomeNote: expectedOutcomeNote || undefined, + }); + }, [ + selectedOutcomeSpecs, + primaryScopeDimensions, + inferredCategories, + initialCategories, + issueStatement, + briefQuestions, + targetMetric, + targetDirection, + targetValue, + stackConfig, + paretoMode, + separateParetoFilename, + expectedOutcomeNote, + onConfirm, + ]); + // ── Render ──────────────────────────────────────────────────────────────── return (
@@ -551,8 +862,8 @@ export const ColumnMapping: React.FC = ({ /> )} - {/* Outcome Selection */} -
+ {/* ── Outcome candidates (Hub-level multi-select) ── */} +
Y @@ -563,100 +874,162 @@ export const ColumnMapping: React.FC = ({

{t('data.outcomeDesc')}

-
- {outcomeColumns.map(col => ( - handleOutcomeChange(col.name)} - onRename={onColumnRename} - /> - ))} -
+ {/* OutcomeNoMatchBanner — surfaces when all candidates score below threshold */} + {allCandidatesBelowThreshold && ( + { + // Delegate to the parent's column rename callback (sets a display alias) + onColumnRename?.(oldName, newName); + }} + onExpectedChange={note => { + // Store the analyst's free-text note; included in confirm payload + setExpectedOutcomeNote(note); + }} + onSkip={() => { + // Clear all selected outcomes — canvas falls back to all-unclassified + setSelectedOutcomeSpecs({}); + }} + /> + )} - {/* Show all toggle for outcome */} - {hasRichData && numericColumns.length < columns.length && ( - + {outcomeCandidates.map(candidate => ( + handleToggleOutcome(candidate.columnName, candidate)} + specs={selectedOutcomeSpecs[candidate.columnName] ?? {}} + onSpecsChange={specs => handleSpecsChange(candidate.columnName, specs)} + /> + ))} +
+ ) : ( + /* Fallback: legacy ColumnCard-based outcome selection when no rich analysis */ +
+
+ {outcomeColumns.map(col => ( + { + const candidate = outcomeCandidates.find( + c => c.columnName === col.name + ) ?? { + columnName: col.name, + type: 'continuous' as const, + characteristicType: 'nominalIsBest' as const, + values: [], + matchScore: 0.5, + qualityReport: { validCount: 0, invalidCount: 0, missingCount: 0 }, + }; + handleToggleOutcome(col.name, candidate); + }} + onRename={onColumnRename} + /> + ))} +
+ {hasRichData && numericColumns.length < columns.length && ( + + )} +
)}
- {/* Factors Selection */} -
-
-
- X + {/* ── Primary scope dimensions (replaces factor-picker in setup) ── */} + {mode === 'setup' && dimensionCandidates.length > 0 && ( + setPrimaryScopeDimensions([])} + /> + )} + + {/* ── Legacy factor selection (edit mode — keeps investigation compat) ── */} + {mode === 'edit' && ( +
+
+
+ X +
+

+ {t('data.selectFactors')} +

+ + {factors.length}/{maxFactors} selected +
-

- {t('data.selectFactors')} -

- - {factors.length}/{maxFactors} selected - -
-

{t('data.factorsDesc')}

- -
- {factorColumns.map(col => { - const isOutcomeCol = outcome === col.name; - const inferred = inferredCategories[col.name]; - return ( - toggleFactor(col.name)} - onRename={onColumnRename} - roleBadge={ - inferred - ? { - categoryName: inferred.categoryName, - categoryColor: categoryColorMap[inferred.categoryName], - matchedKeyword: inferred.keyword, - onDismiss: () => - setDismissedRoles(prev => new Set([...prev, col.name])), - } - : undefined - } - /> - ); - })} -
+

{t('data.factorsDesc')}

- {/* Show all toggle for factors */} - {hasRichData && nonNumericColumns.length < columns.length && ( - - )} -
+
+ {factorColumns.map(col => { + const isOutcomeCol = selectedOutcomeNames.has(col.name); + const inferred = inferredCategories[col.name]; + return ( + toggleFactor(col.name)} + onRename={onColumnRename} + roleBadge={ + inferred + ? { + categoryName: inferred.categoryName, + categoryColor: categoryColorMap[inferred.categoryName], + matchedKeyword: inferred.keyword, + onDismiss: () => + setDismissedRoles(prev => new Set([...prev, col.name])), + } + : undefined + } + /> + ); + })} +
+ + {hasRichData && nonNumericColumns.length < columns.length && ( + + )} +
+ )} - {/* Specification Limits (hidden in edit mode or defect mode — specs have their own editor / Cpk not applicable) */} - {mode === 'setup' && !hideSpecs && ( + {/* Specification Limits (edit mode only — in setup mode specs are inline per row) */} + {mode === 'edit' && !hideSpecs && ( setSpecsExpanded(!specsExpanded)} @@ -704,69 +1077,7 @@ export const ColumnMapping: React.FC = ({