Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
73 changes: 73 additions & 0 deletions frontend/src/__tests__/components/hero-workbench.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HeroWorkbench } from '@/components/marketing/HeroWorkbench';
import { gapIndex, scenarios } from '@/lib/demo-workspace/model';

vi.mock('@/lib/conversion-events', () => ({
trackScenarioTab: vi.fn(),
trackRowClick: vi.fn(),
trackSendCorrectionLink: vi.fn(),
trackResetDemo: vi.fn(),
trackToggleRows: vi.fn(),
trackBookCallClick: vi.fn(),
}));

import * as events from '@/lib/conversion-events';

const first = scenarios()[0];

function gapRow() {
return screen.getByText('missing').closest('tr') as HTMLTableRowElement;
}

describe('HeroWorkbench', () => {
beforeEach(() => vi.clearAllMocks());

it('shows four rows around the gap and a guided caption until a row is clicked', () => {
render(<HeroWorkbench />);
expect(screen.getAllByRole('row')).toHaveLength(5); // header + 4
expect(screen.getByText(/one record is missing a required field/i)).toBeInTheDocument();
expect(screen.getByText(/show \d+ more records? in this chain/i)).toBeInTheDocument();

fireEvent.click(gapRow());
expect(screen.queryByText(/one record is missing a required field/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /send correction link to supplier/i })).toBeInTheDocument();
expect(events.trackRowClick).toHaveBeenCalledWith('landing', first.id, first.chain[gapIndex(first)].cte, true);
});

it('commits the record when the correction link is sent and restores it on reset', () => {
render(<HeroWorkbench />);
expect(screen.getByText(/of \d+ ready/i)).toBeInTheDocument();

fireEvent.keyDown(gapRow(), { key: 'Enter' });
fireEvent.click(screen.getByRole('button', { name: /send correction link to supplier/i }));

expect(screen.getByText(/supplier corrected the record/i)).toBeInTheDocument();
expect(screen.getByText('Ready to export')).toBeInTheDocument();
expect(screen.getByText('Corrected')).toBeInTheDocument();
expect(screen.queryByText('missing')).not.toBeInTheDocument();
const correctedRow = screen.getByText('Corrected').closest('tr') as HTMLTableRowElement;
expect(correctedRow).toHaveTextContent(first.lot);
expect(events.trackSendCorrectionLink).toHaveBeenCalledWith('landing', first.id, first.chain[gapIndex(first)].cte);

fireEvent.click(screen.getByRole('button', { name: /reset demo/i }));
expect(screen.getByText('missing')).toBeInTheDocument();
expect(screen.queryByText('Ready to export')).not.toBeInTheDocument();
expect(events.trackResetDemo).toHaveBeenCalledWith('landing', first.id);
});

it('expands to the whole chain and switches chains from the food tabs', () => {
render(<HeroWorkbench />);
fireEvent.click(screen.getByText(/show \d+ more records? in this chain/i));
expect(screen.getAllByRole('row')).toHaveLength(first.chain.length + 1);
expect(screen.getByText(/show fewer records/i)).toBeInTheDocument();
expect(events.trackToggleRows).toHaveBeenCalledWith('landing', first.id);

const tabs = screen.getAllByRole('tab');
fireEvent.click(tabs[2]);
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
expect(events.trackScenarioTab).toHaveBeenCalledWith('landing', scenarios()[2].id);
// switching chains collapses the table again
expect(screen.getAllByRole('row')).toHaveLength(5);
});
});
11 changes: 10 additions & 1 deletion frontend/src/__tests__/lib/app-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isAuthenticatedAppRoute, shouldHideMarketingChrome } from '@/lib/app-routes';
import { isAuthenticatedAppRoute, isForcedDarkRoute, shouldHideMarketingChrome } from '@/lib/app-routes';

describe('app route ownership', () => {
it('treats operational root routes as authenticated app routes', () => {
Expand Down Expand Up @@ -33,4 +33,13 @@ describe('app route ownership', () => {
// Portal routes carry their own chrome but are NOT middleware-auth routes.
expect(isAuthenticatedAppRoute('/developer/portal')).toBe(false);
});

it('gives the public demo workspace its own chrome without an auth gate, and forces dark on it and the landing page', () => {
expect(shouldHideMarketingChrome('/demo')).toBe(true);
expect(isAuthenticatedAppRoute('/demo')).toBe(false);
expect(isForcedDarkRoute('/')).toBe(true);
expect(isForcedDarkRoute('/demo')).toBe(true);
expect(isForcedDarkRoute('/pricing')).toBe(false);
expect(isForcedDarkRoute('/demo/anything')).toBe(false);
});
});
16 changes: 16 additions & 0 deletions frontend/src/__tests__/lib/conversion-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isInternalAnalyticsSession,
setConversionAnalyticsEnabled,
trackConversionEvent,
trackRowClick,
trackToolRunStarted,
} from '@/lib/conversion-events';

Expand Down Expand Up @@ -203,3 +204,18 @@ describe('conversion events reach PostHog', () => {
expect(captureEvent).not.toHaveBeenCalled();
});
});

describe('public workbench events', () => {
it('keeps the surface, scenario, CTE and gap flag and nothing else', () => {
window.history.replaceState({}, '', '/demo');
trackRowClick('workspace', 'peppers', 'Initial packing', true);
expect(track).toHaveBeenLastCalledWith('row_click', {
path: '/demo', surface: 'workspace', scenario: 'peppers', cte: 'initial_packing', gap: true,
});

trackConversionEvent('send_correction_link', {
surface: 'elsewhere', scenario: 'Rancho Las Lomas', cte: 'Receiving', gap: 'yes', email: 'person@example.com',
});
expect(track).toHaveBeenLastCalledWith('send_correction_link', { path: '/demo', cte: 'receiving' });
});
});
161 changes: 161 additions & 0 deletions frontend/src/__tests__/lib/demo-workspace-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import {
CSV_HEADER,
attentionRows,
buildCsv,
deriveLedger,
derivePackages,
deriveRows,
detailFor,
fixQueue,
gapIndex,
heroStatus,
nextActions,
overviewStats,
parseView,
recordState,
scenarios,
stableHash,
windowRows,
} from '@/lib/demo-workspace/model';

const all = scenarios();
const first = all[0];

describe('demo workspace dataset', () => {
it('ships five chains with exactly one gap each', () => {
expect(all).toHaveLength(5);
for (const scenario of all) {
expect(scenario.chain.length).toBeGreaterThanOrEqual(6);
expect(scenario.chain.length).toBeLessThanOrEqual(7);
expect(scenario.chain.filter((r) => r.gap)).toHaveLength(1);
expect(scenario.hue).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});

describe('record state and rows', () => {
it('marks the gap row open until the scenario is fixed', () => {
const idx = gapIndex(first);
const gap = first.chain[idx];
expect(recordState(gap, false)).toBe('needs-correction');
expect(recordState(gap, true)).toBe('corrected');
expect(recordState(first.chain[0], false)).toBe('committed');

const open = deriveRows(first, {});
expect(open[idx].lot).toBe('missing');
expect(open[idx].openGap).toBe(true);

const fixed = deriveRows(first, { [first.id]: true });
expect(fixed[idx].lot).toBe(first.lot);
expect(fixed[idx].state).toBe('corrected');
expect(fixed[idx].openGap).toBe(false);
});

it('windows four rows around the gap and expands to the full chain', () => {
const rows = deriveRows(first, {});
const idx = gapIndex(first);
const collapsed = windowRows(rows, idx, false);
expect(collapsed.visible).toHaveLength(4);
expect(collapsed.visible.some((r) => r.index === idx)).toBe(true);
expect(collapsed.hidden).toBe(rows.length - 4);
const expanded = windowRows(rows, idx, true);
expect(expanded.visible).toHaveLength(rows.length);
expect(expanded.hidden).toBe(0);
});

it('describes the gap, then the corrected record, in the detail panel', () => {
const idx = gapIndex(first);
const gap = detailFor(first, idx, {}, 'landing');
expect(gap?.kind).toBe('gap');
expect(gap?.canFix).toBe(true);
expect(gap?.kv.map(([k]) => k)).toEqual(['Owner', 'Recovery', 'Date']);

const workspaceGap = detailFor(first, idx, {}, 'workspace');
expect(workspaceGap?.kv.map(([k]) => k)).toEqual(['Owner', 'Recovery', 'Date', 'Location']);

const corrected = detailFor(first, idx, { [first.id]: true }, 'workspace');
expect(corrected?.kind).toBe('corrected');
expect(corrected?.canFix).toBe(false);
expect(corrected?.kv.map(([k]) => k)).toContain('Event hash');

expect(detailFor(first, null, {}, 'landing')).toBeNull();
});
});

describe('hashes, ledger and packages', () => {
it('produces deterministic stand-in hashes', () => {
expect(stableHash('x')).toBe(stableHash('x'));
expect(stableHash('x')).not.toBe(stableHash('y'));
expect(stableHash('x')).toMatch(/^[0-9a-f]{8}…[0-9a-f]{4}$/);
});

it('chains every committed record and appends one entry per fix', () => {
const total = all.reduce((sum, s) => sum + s.chain.length, 0);
const open = deriveLedger(all, {});
expect(open).toHaveLength(total - all.length);
const after = deriveLedger(all, { [first.id]: true });
expect(after).toHaveLength(open.length + 1);
for (const entry of after) {
expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00-07:00$/);
expect(entry.chainHash).not.toBe(entry.eventHash);
}
expect(new Set(after.map((e) => e.chainHash)).size).toBe(after.length);
});

it('writes an OPEN row instead of dropping the gap from the CSV', () => {
const csv = buildCsv(first, {});
const lines = csv.split('\n');
expect(lines[0]).toBe(CSV_HEADER);
expect(lines).toHaveLength(first.chain.length + 1);
expect(lines[gapIndex(first) + 1]).toContain('"OPEN"');
expect(lines[gapIndex(first) + 1]).toContain('""');
const fixedCsv = buildCsv(first, { [first.id]: true });
expect(fixedCsv).not.toContain('"OPEN"');
expect(fixedCsv).toContain(`"${first.lot}"`);
});

it('flips a package to ready once its chain is fixed', () => {
const before = derivePackages(all, {});
expect(before.every((p) => !p.ready)).toBe(true);
expect(before[0].badge).toBe(`${first.chain.length - 1} OF ${first.chain.length} READY`);
const after = derivePackages(all, { [first.id]: true });
expect(after[0].ready).toBe(true);
expect(after[0].badge).toBe('READY');
expect(after[0].manifest).not.toBe(before[0].manifest);
});

it('summarises the hero status strip', () => {
const open = heroStatus(first, {});
expect(open.open).toBe(1);
expect(open.exportReady).toBe(false);
expect(open.ledgerLine).toBe(`${first.chain.length - 1} of ${first.chain.length} records committed`);
const done = heroStatus(first, { [first.id]: true });
expect(done.exportReady).toBe(true);
expect(done.exportBadge).toBe('Ready to export');
});
});

describe('overview derivations', () => {
it('counts open fixes down as chains are fixed', () => {
expect(overviewStats(all, {}).open).toBe(5);
expect(overviewStats(all, { [first.id]: true }).open).toBe(4);
expect(attentionRows(all, {})).toHaveLength(5);
expect(attentionRows(all, { [first.id]: true })).toHaveLength(4);
expect(fixQueue(all, { [first.id]: true }).filter((f) => f.done)).toHaveLength(1);
});

it('orders next actions and drops the fix step once the queue is clear', () => {
const everything = Object.fromEntries(all.map((s) => [s.id, true]));
expect(nextActions(all, {}).map((a) => a.view)).toEqual(['fix', 'export', 'ledger']);
expect(nextActions(all, everything).map((a) => a.view)).toEqual(['export', 'ledger']);
expect(nextActions(all, {})[0].n).toBe('01');
});

it('parses the view from the location hash', () => {
expect(parseView('#ledger')).toBe('ledger');
expect(parseView('ledger')).toBe('ledger');
expect(parseView('#nope')).toBe('overview');
expect(parseView('')).toBe('overview');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('marketing pageview routes', () => {
it('normalizes arbitrary public child paths so names and encoded email never become pageview URLs', () => {
document.cookie = 're_cookie_consent=accepted; path=/';
expect(safeMarketingPath('/pricing/Jane-Sellers')).toBe('/pricing');
expect(safeMarketingPath('/demo')).toBe('/demo');
expect(safeMarketingPath('/tools/person%2540example.com')).toBe('/tools');
expect(safeMarketingPath('/Jane-Sellers')).toBeNull();
expect(shouldTrackMarketingPageview('/pricing/Jane-Sellers')).toBe(true);
Expand Down
Loading
Loading