Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
73c3e0e
refactor(ColumnMapping): Hub-level mapper with new onConfirm contract…
jukka-matti May 4, 2026
2117c8f
feat: migrate all ColumnMapping call sites to new onConfirm contract …
jukka-matti May 4, 2026
802730a
feat(core): add isProcessHubComplete() — framing-layer canvas branch …
jukka-matti May 4, 2026
308fdd0
feat(pwa): Task D — mount framing toolbar (OutcomePin, SaveToBrowserB…
jukka-matti May 4, 2026
bfa3049
feat(pwa): Task E — HomeScreen + Import .vrs secondary action
jukka-matti May 4, 2026
bfcb8bf
feat(azure): Task F — features/hubCreation slice (HubCreationFlow + u…
jukka-matti May 4, 2026
bf630f2
feat(azure): Task G — ProjectDashboard + New Hub button (Mode B entry)
jukka-matti May 4, 2026
d72ee9d
feat(azure): Task H — ProcessHubView framing-complete gating + goal-c…
jukka-matti May 4, 2026
84fd643
feat(azure): Tasks I+J — Editor Mode B wiring + HubCreationFlow tests
jukka-matti May 4, 2026
4347b58
fix(ui): remove ColumnMapping back-compat shim + migrate consumers (F…
jukka-matti May 4, 2026
53f3611
fix(ui): GoalBanner non-empty validation (Finding 2)
jukka-matti May 4, 2026
badc0fa
feat(azure): wire Dashboard onHubGoalChange + onEditFraming (Finding 3)
jukka-matti May 4, 2026
6ee9e64
feat(azure): paint OutcomePin per outcome in ProcessHubView (Finding 5)
jukka-matti May 4, 2026
e1c6d8b
fix(pwa): render OutcomePin per outcome + multi-outcome test (Finding 6)
jukka-matti May 4, 2026
148b355
test(pwa): Mode B E2E — full flow + cryptic-column no-match + .vrs Im…
jukka-matti May 4, 2026
2230f0f
test(azure): Mode B E2E — Editor paste flow + ProjectDashboard New Hub
jukka-matti May 4, 2026
dee59d7
fix(ui): preload existing Hub outcomes/dimensions in edit mode (Criti…
jukka-matti May 4, 2026
0deaea2
fix(ui): OutcomeCandidateRow radio → checkbox + selector updates (Cri…
jukka-matti May 4, 2026
6fa6e3b
feat(ui): wire OutcomeNoMatchBanner rename/skip/expectedChange CTAs (…
jukka-matti May 4, 2026
435d51a
refactor(azure): consolidate Dashboard handleCreateHub on useNewHubPr…
jukka-matti May 4, 2026
d21cb59
docs(decision-log): record ProcessHubView empty-state redirect-vs-inl…
jukka-matti May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions apps/azure/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -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();
}

/**
Expand Down Expand Up @@ -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<void> {
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<void> {
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.
Expand Down
279 changes: 279 additions & 0 deletions apps/azure/e2e/modeB-framing.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading