diff --git a/src/renderer/components/organize/OrganizePreviewModal.jsx b/src/renderer/components/organize/OrganizePreviewModal.jsx new file mode 100644 index 0000000..2f5d877 --- /dev/null +++ b/src/renderer/components/organize/OrganizePreviewModal.jsx @@ -0,0 +1,191 @@ +import React, { memo, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { ArrowRight, FolderPlus, FolderInput, AlertTriangle } from 'lucide-react'; +import Modal from '../ui/Modal'; +import { Button, StatusBadge } from '../ui'; +import { Text } from '../ui/Typography'; +import { Inline } from '../layout'; +import { formatDisplayFileName, formatDisplayPath } from '../../utils/pathDisplay'; + +const SKIP_REASONS = { + 'source-missing': 'File is no longer at its original location', + 'invalid-operation': 'No destination could be worked out' +}; + +/** Split "C:\Docs\Finance\a.pdf" into its folder and file name. */ +function splitPath(p) { + if (typeof p !== 'string' || !p) return { folder: '', name: '' }; + const sep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + return sep < 0 ? { folder: '', name: p } : { folder: p.slice(0, sep), name: p.slice(sep + 1) }; +} + +/** + * Shows the dry-run plan from previewBatchOrganize before anything moves: + * every file grouped under the folder it will land in, its new name when the + * move renames it, folders that will be created, and name clashes the move + * will resolve with a numeric suffix. + */ +function OrganizePreviewModal({ isOpen, plan, onClose, onConfirm, redactPaths, isOrganizing }) { + const { groups, newFolders } = useMemo(() => { + const created = new Set(Array.isArray(plan?.wouldCreateFolders) ? plan.wouldCreateFolders : []); + /** @type {Map>} */ + const byFolder = new Map(); + for (const item of Array.isArray(plan?.wouldMove) ? plan.wouldMove : []) { + const dest = splitPath(item?.destination); + const src = splitPath(item?.source); + const list = byFolder.get(dest.folder) || []; + list.push({ + key: item?.source || `${dest.folder}|${dest.name}`, + fromName: src.name, + toName: dest.name, + collision: Boolean(item?.collision) + }); + byFolder.set(dest.folder, list); + } + return { + groups: Array.from(byFolder.entries()).sort((a, b) => b[1].length - a[1].length), + newFolders: created + }; + }, [plan]); + + const moveCount = Array.isArray(plan?.wouldMove) ? plan.wouldMove.length : 0; + const skipped = Array.isArray(plan?.skipped) ? plan.skipped : []; + const folderLabel = (folder) => + redactPaths ? formatDisplayPath(folder, { redact: true, segments: 1 }) : folder; + const nameLabel = (name) => formatDisplayFileName(name, { redact: redactPaths }); + + return ( + + + + + } + > +
+ {groups.map(([folder, files]) => ( +
+
+ + {folderLabel(folder)} + + {newFolders.has(folder) && ( + + + New folder + + )} + + {files.length} file{files.length !== 1 ? 's' : ''} + +
+
    + {files.map((f) => { + const renamed = f.fromName && f.toName && f.fromName !== f.toName; + return ( +
  • + + {nameLabel(f.fromName)} + + {renamed && ( + <> + + + {nameLabel(f.toName)} + + + )} + {f.collision && ( + + Name taken — number added + + )} +
  • + ); + })} +
+
+ ))} + {skipped.length > 0 && ( +
+
+ + + {skipped.length} file{skipped.length !== 1 ? 's' : ''} will not move + +
+
    + {skipped.map((s, i) => ( +
  • + + {nameLabel(splitPath(s?.source).name) || 'Unknown file'} + + + {SKIP_REASONS[s?.reason] || 'Skipped'} + +
  • + ))} +
+
+ )} +
+
+ ); +} + +OrganizePreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + plan: PropTypes.shape({ + wouldMove: PropTypes.arrayOf( + PropTypes.shape({ + source: PropTypes.string, + destination: PropTypes.string, + collision: PropTypes.bool + }) + ), + skipped: PropTypes.arrayOf( + PropTypes.shape({ source: PropTypes.string, reason: PropTypes.string }) + ), + wouldCreateFolders: PropTypes.arrayOf(PropTypes.string) + }), + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + redactPaths: PropTypes.bool, + isOrganizing: PropTypes.bool +}; + +export default memo(OrganizePreviewModal); diff --git a/src/renderer/phases/CompletePhase.jsx b/src/renderer/phases/CompletePhase.jsx index 05fffce..083a538 100644 --- a/src/renderer/phases/CompletePhase.jsx +++ b/src/renderer/phases/CompletePhase.jsx @@ -1,7 +1,7 @@ import React, { memo, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import { motion, useReducedMotion } from 'framer-motion'; -import { CheckCircle2, Check, ArrowLeft, FolderOpen, RotateCcw } from 'lucide-react'; +import { CheckCircle2, Check, ArrowLeft, FolderOpen, FilePlus } from 'lucide-react'; import { PHASES } from '../../shared/constants'; import { useAppSelector, useAppDispatch } from '../store/hooks'; import { setPhase, resetUi } from '../store/slices/uiSlice'; @@ -19,6 +19,7 @@ import { filesIpc } from '../services/ipc'; import { logger } from '../../shared/logger'; import { toCanonicalPath } from '../utils/canonicalPath'; import { collectOrganizedPathKeys, mergeOrganizedPathIndex } from '../utils/organizedPathIndex'; +import { useFileActions } from '../hooks/useFileActions'; /** * Pick a folder path safe to hand to shell.openPath — prefers `newLocation` @@ -37,25 +38,39 @@ function pickOpenableFolder(file) { return null; } -const FileRow = memo(function FileRow({ file, index, redactPaths }) { +/** Last segment of a file or folder path ("C:\Docs\Finance" -> "Finance"). */ +function baseName(p) { + if (typeof p !== 'string' || !p) return ''; + const trimmed = p.replace(/[\\/]+$/, ''); + return trimmed.slice(Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + 1); +} + +const FileRow = memo(function FileRow({ file, index, redactPaths, onReveal }) { if (!file || typeof file !== 'object') { return null; } const originalName = file.originalName || file.name || `File ${index + 1}`; - const fullPath = file.path || file.newLocation || file.destination || ''; + const filePath = file.path || ''; + const newName = file.newName || baseName(filePath); + const renamed = Boolean(newName) && newName !== originalName; + const folder = pickOpenableFolder(file); const displayName = formatDisplayFileName(originalName, { redact: redactPaths }); - const displayPath = redactPaths + const displayFolder = redactPaths ? 'Destination hidden' - : fullPath - ? formatDisplayPath(fullPath, { redact: false, segments: 2 }) + : folder + ? formatDisplayPath(folder, { redact: false, segments: 2 }) : 'Organized'; + // Say where the file went and, when the move renamed it, what it is called + // now — the original name alone does not tell the user what to look for. + const destinationLine = + renamed && !redactPaths ? `Renamed to ${newName} · in ${displayFolder}` : displayFolder; const rowTitle = redactPaths - ? `${displayName} -> ${displayPath}` - : `${originalName} → ${displayPath}`; + ? `${displayName} -> ${displayFolder}` + : `${originalName} → ${filePath || displayFolder}`; return (
@@ -64,9 +79,21 @@ const FileRow = memo(function FileRow({ file, index, redactPaths }) { {displayName} - {displayPath} + {destinationLine}
+ {filePath && onReveal && ( + + )} ); }); @@ -157,11 +184,14 @@ function CompletePhase() { const actions = useMemo( () => ({ advancePhase: (phase) => dispatch(setPhase(phase)), - resetWorkflow: () => { + // Clear this finished batch and go straight to Discover for the next + // one. Landing on Welcome made the user find Discover in the phase bar. + analyzeMoreFiles: () => { // React 18+ batches state updates automatically dispatch(resetUi()); dispatch(resetFilesState()); dispatch(resetAnalysisState()); + dispatch(setPhase(PHASES?.DISCOVER ?? 'discover')); // Kill any save still pending in the debounce window before we clear // storage — otherwise that writer fires ~1s later and re-persists the // key we are about to remove. Synchronous, so no timer survives. @@ -180,7 +210,11 @@ function CompletePhase() { const handleOpenFolder = useCallback(async () => { if (!primaryDestination) return; try { - await filesIpc.openFolder(primaryDestination); + const result = await filesIpc.openFolder(primaryDestination); + // The handler reports failure in the result rather than throwing. + if (result && result.success === false) { + throw new Error(result.error || 'openFolder failed'); + } } catch (error) { logger.warn('[CompletePhase] Failed to open organized folder', { error: error?.message || String(error) @@ -189,7 +223,17 @@ function CompletePhase() { } }, [primaryDestination, showError]); + const { revealFile } = useFileActions(showError); + const canOpenFolder = Boolean(primaryDestination) && totalFiles > 0; + // Name the folder on the button: "Open organized folder" did not say which + // one, and with several destinations it opens only the busiest of them. + const primaryFolderName = redactPaths ? '' : baseName(primaryDestination); + const openFolderLabel = primaryFolderName ? `Open ${primaryFolderName}` : 'Open folder'; + const openFolderHint = + destinationCount > 1 + ? `Opens ${redactPaths ? 'the folder' : primaryDestination} — it received the most files. Use the folder button on a row to see any other file.` + : `Opens ${redactPaths ? 'the folder these files were moved to' : primaryDestination}`; const showCelebration = totalFiles > 0 && !shouldReduceMotion; @@ -350,6 +394,7 @@ function CompletePhase() { file={file} index={index} redactPaths={redactPaths} + onReveal={revealFile} /> ))} {overflowCount > 0 && ( @@ -372,10 +417,9 @@ function CompletePhase() { - {/* Footer Navigation — primary success-path action on the right, with - "Start over" as a ghost secondary. The "Review organized files" - back-affordance lives on the left as a tertiary because most users - finish here; only those who want to verify need to step backward. */} + {/* Footer Navigation — the primary action continues the workflow with + the next batch (Discover). Opening the destination is a named + secondary; "Review organized files" steps back to Organize. */} + {canOpenFolder && ( + + )} - @@ -423,11 +463,13 @@ FileRow.propTypes = { originalName: PropTypes.string, name: PropTypes.string, path: PropTypes.string, + newName: PropTypes.string, newLocation: PropTypes.string, destination: PropTypes.string }), index: PropTypes.number.isRequired, - redactPaths: PropTypes.bool + redactPaths: PropTypes.bool, + onReveal: PropTypes.func }; export default memo(CompletePhase); diff --git a/src/renderer/phases/DiscoverPhase.jsx b/src/renderer/phases/DiscoverPhase.jsx index 3fe5ac9..0bbc57d 100644 --- a/src/renderer/phases/DiscoverPhase.jsx +++ b/src/renderer/phases/DiscoverPhase.jsx @@ -145,6 +145,15 @@ function DiscoverPhase() { // Canonical queue stats (Dashboard / Organize share selectFileStats). const visibleReadyCount = fileStats?.ready ?? 0; const visibleFailedCount = fileStats?.failed ?? 0; + // "N files ready" counted every added file, analyzed or not, and grew the + // moment files were added mid-run. Only call files ready once analyzed. + const selectionBadgeLabel = useMemo(() => { + const plural = (n) => `${n} file${n !== 1 ? 's' : ''}`; + if (visibleReadyCount >= unorganizedSelectedCount) { + return `${plural(unorganizedSelectedCount)} ready`; + } + return `${plural(unorganizedSelectedCount)} added · ${visibleReadyCount} analyzed`; + }, [unorganizedSelectedCount, visibleReadyCount]); const pendingAnalysisFiles = useMemo(() => { // Canonical-key lookup: fileStates keys and selected paths can disagree @@ -607,8 +616,10 @@ function DiscoverPhase() { {unorganizedSelectedCount > 0 && ( - - {unorganizedSelectedCount} file{unorganizedSelectedCount !== 1 ? 's' : ''} ready + = unorganizedSelectedCount ? 'success' : 'info'} + > + {selectionBadgeLabel} )} diff --git a/src/renderer/phases/OrganizePhase.jsx b/src/renderer/phases/OrganizePhase.jsx index 45f2f40..612343b 100644 --- a/src/renderer/phases/OrganizePhase.jsx +++ b/src/renderer/phases/OrganizePhase.jsx @@ -35,6 +35,7 @@ import { import { setSmartFolders as setSmartFoldersAction } from '../store/slices/filesSlice'; import { UndoRedoToolbar, useUndoRedo } from '../components/UndoRedoSystem'; import Modal from '../components/ui/Modal'; +import OrganizePreviewModal from '../components/organize/OrganizePreviewModal'; import AnalysisDetails from '../components/AnalysisDetails'; import { useOrganizeState, useLoadInitialData } from './organize/useOrganizeState'; import { @@ -83,6 +84,7 @@ function OrganizePhase() { const [showStatusModal, setShowStatusModal] = React.useState(false); const [showHistoryModal, setShowHistoryModal] = React.useState(false); const [focusFileIndex, setFocusFileIndex] = React.useState(null); + const [previewPlan, setPreviewPlan] = React.useState(null); const { revealFile } = useFileActions(); const { @@ -828,22 +830,9 @@ function OrganizePhase() { addNotification('Nothing to preview.', 'info', 4000, 'organize-preview'); return; } - const movedSummary = - plan.wouldMove.length === 1 ? '1 file' : `${plan.wouldMove.length} files`; - const conflictSummary = - plan.conflicts > 0 ? `, ${plan.conflicts} renamed to avoid collisions` : ''; - const folderSummary = - plan.wouldCreateFolders.length > 0 - ? `, ${plan.wouldCreateFolders.length} new folder${plan.wouldCreateFolders.length !== 1 ? 's' : ''}` - : ''; - const skippedSummary = - plan.skipped.length > 0 ? ` (${plan.skipped.length} skipped)` : ''; - addNotification( - `Preview: would move ${movedSummary}${conflictSummary}${folderSummary}${skippedSummary}. Click Move to commit.`, - 'info', - 8000, - 'organize-preview' - ); + // Show each file's destination folder and new name — a + // count-only toast did not say where anything would go. + setPreviewPlan(plan); }} variant="secondary" size="md" @@ -959,6 +948,18 @@ function OrganizePhase() { )} + setPreviewPlan(null)} + onConfirm={() => { + setPreviewPlan(null); + handleOrganizeClick(); + }} + redactPaths={Boolean(redactPaths)} + isOrganizing={isOrganizing} + /> + {/* Smart Folders Modal */} ({ jest.mock('lucide-react', () => ({ CheckCircle2: () => , Check: () => , - RotateCcw: () => , + FilePlus: () => , ArrowLeft: () => , FolderOpen: () => })); +const mockRevealFile = jest.fn(); +jest.mock('../../src/renderer/hooks/useFileActions', () => ({ + useFileActions: () => ({ revealFile: mockRevealFile }) +})); + jest.mock('../../src/renderer/contexts/NotificationContext', () => ({ useNotification: () => ({ showError: jest.fn(), @@ -139,10 +144,8 @@ describe('CompletePhase premium finish', () => { expect(screen.getByText(/Step 5 of 5/i)).toBeInTheDocument(); expect(screen.getAllByText(/Session summary/i).length).toBeGreaterThan(0); - expect(screen.getByText(/Start over/i)).toBeInTheDocument(); - expect( - screen.getAllByText(/Review organized files|Open organized folder/i).length - ).toBeGreaterThan(0); + expect(screen.getByText(/Analyze more files/i)).toBeInTheDocument(); + expect(screen.getByText(/Review organized files/i)).toBeInTheDocument(); }); test('allows the top completion card to be minimized', () => { @@ -168,15 +171,84 @@ describe('CompletePhase premium finish', () => { expect(screen.getAllByText(/Destination hidden/i).length).toBeGreaterThan(0); }); - test('Start over cancels any pending persistence write so cleared storage stays cleared', () => { + test('Analyze more files cancels any pending persistence write so cleared storage stays cleared', () => { mockCancelPendingPersistence.mockClear(); render(); - fireEvent.click(screen.getByText(/Start over/i)); + fireEvent.click(screen.getByText(/Analyze more files/i)); expect(mockCancelPendingPersistence).toHaveBeenCalled(); }); + test('Analyze more files clears the batch and lands on Discover, not Welcome', () => { + const { setPhase, resetUi } = require('../../src/renderer/store/slices/uiSlice'); + setPhase.mockClear(); + resetUi.mockClear(); + + render(); + fireEvent.click(screen.getByText(/Analyze more files/i)); + + expect(resetUi).toHaveBeenCalled(); + expect(setPhase).toHaveBeenCalledWith('discover'); + }); + + test('the open-folder button names the folder it opens', () => { + selectRedactPaths.mockReturnValue(false); + const { useAppSelector } = require('../../src/renderer/store/hooks'); + const defaultSelector = useAppSelector.getMockImplementation(); + useAppSelector.mockImplementation((selector) => + selector({ + files: { + organizedFiles: [ + { originalName: 'a.pdf', path: 'C:\\Docs\\Finance\\a.pdf' }, + { originalName: 'b.pdf', path: 'C:\\Docs\\Finance\\b.pdf' } + ], + organizedPathIndex: [] + }, + analysis: { results: [] } + }) + ); + try { + render(); + expect(screen.getByText('Open Finance')).toBeInTheDocument(); + expect(screen.queryByText(/Open organized folder/i)).not.toBeInTheDocument(); + } finally { + useAppSelector.mockImplementation(defaultSelector); + } + }); + + test('a renamed file shows its new name and folder, and can be shown in its folder', () => { + selectRedactPaths.mockReturnValue(false); + mockRevealFile.mockClear(); + const { useAppSelector } = require('../../src/renderer/store/hooks'); + const defaultSelector = useAppSelector.getMockImplementation(); + useAppSelector.mockImplementation((selector) => + selector({ + files: { + organizedFiles: [ + { + originalName: 'Tiller Template (3).xlsx', + path: 'C:\\Docs\\Finance\\transaction-log.xlsx' + } + ], + organizedPathIndex: [] + }, + analysis: { results: [] } + }) + ); + try { + render(); + expect( + screen.getByText('Renamed to transaction-log.xlsx · in C:\\Docs\\Finance') + ).toBeInTheDocument(); + const row = screen.getByLabelText('Organized file 1'); + fireEvent.click(row.querySelector('button')); + expect(mockRevealFile).toHaveBeenCalledWith('C:\\Docs\\Finance\\transaction-log.xlsx'); + } finally { + useAppSelector.mockImplementation(defaultSelector); + } + }); + describe('counts in the summary', () => { const { useAppSelector } = require('../../src/renderer/store/hooks'); const { selectOrganizedCount } = require('../../src/renderer/store/selectors'); diff --git a/test/components/DiscoverPhase.test.js b/test/components/DiscoverPhase.test.js index aa3b821..8113b2c 100644 --- a/test/components/DiscoverPhase.test.js +++ b/test/components/DiscoverPhase.test.js @@ -238,6 +238,32 @@ describe('DiscoverPhase premium guidance', () => { expect(screen.getByText(/1 file ready to review/i)).toBeInTheDocument(); }); + test('the selection badge says "ready" only for analyzed files', () => { + const { useDiscoverState } = require('../../src/renderer/phases/discover'); + const defaultImpl = useDiscoverState.getMockImplementation(); + useDiscoverState.mockImplementation(() => ({ + ...defaultImpl(), + selectedFiles: [ + { path: '/tmp/example.pdf', name: 'example.pdf', size: 1024 }, + { path: '/tmp/added-mid-run-1.pdf', name: 'added-mid-run-1.pdf', size: 1024 }, + { path: '/tmp/added-mid-run-2.pdf', name: 'added-mid-run-2.pdf', size: 1024 } + ], + fileStats: { total: 3, ready: 1, failed: 0, pending: 2 } + })); + try { + render(); + expect(screen.getByText('3 files added · 1 analyzed')).toBeInTheDocument(); + expect(screen.queryByText('3 files ready')).not.toBeInTheDocument(); + } finally { + useDiscoverState.mockImplementation(defaultImpl); + } + }); + + test('the selection badge says "ready" once every file is analyzed', () => { + render(); + expect(screen.getByText('1 file ready')).toBeInTheDocument(); + }); + // Regression: after analysis, successful results must not keep the sticky // queue bar stuck on "waiting for analysis" just because fileStates is empty // or still lists the path as pending/ready for re-run. diff --git a/test/components/OrganizePreviewModal.test.js b/test/components/OrganizePreviewModal.test.js new file mode 100644 index 0000000..5437c2d --- /dev/null +++ b/test/components/OrganizePreviewModal.test.js @@ -0,0 +1,115 @@ +/** + * @jest-environment jsdom + * + * The Organize "Preview" shows where each file will go before anything + * moves: files grouped by destination folder, the new name when the move + * renames a file, folders the move will create, name clashes, and files + * that will be skipped. + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../src/renderer/components/ui/Modal', () => ({ + __esModule: true, + default: ({ isOpen, title, description, children, footer }) => + isOpen ? ( +
+

{title}

+

{description}

+ {children} + {footer} +
+ ) : null +})); + +import OrganizePreviewModal from '../../src/renderer/components/organize/OrganizePreviewModal'; + +const plan = { + wouldMove: [ + { + source: 'C:\\Downloads\\Tiller Template (3).xlsx', + destination: 'C:\\Docs\\Finance\\transaction-log.xlsx', + collision: false + }, + { + source: 'C:\\Downloads\\report.md', + destination: 'C:\\Docs\\Reports\\report-1.md', + collision: true + }, + { + source: 'C:\\Downloads\\budget.xlsx', + destination: 'C:\\Docs\\Finance\\budget.xlsx', + collision: false + } + ], + skipped: [{ source: 'C:\\Downloads\\gone.pdf', reason: 'source-missing' }], + wouldCreateFolders: ['C:\\Docs\\Reports'], + conflicts: 1 +}; + +const renderModal = (props = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + render( + + ); + return { onClose, onConfirm }; +}; + +describe('OrganizePreviewModal', () => { + test('groups files under the folder they will land in', () => { + renderModal(); + + expect(screen.getByText('3 files into 2 folders. Nothing has moved yet.')).toBeInTheDocument(); + const finance = screen.getByRole('region', { name: 'Destination C:\\Docs\\Finance' }); + expect(finance).toHaveTextContent('2 files'); + expect(finance).toHaveTextContent('budget.xlsx'); + expect(screen.getByRole('region', { name: 'Destination C:\\Docs\\Reports' })).toHaveTextContent( + '1 file' + ); + }); + + test('shows the new name when the move renames a file', () => { + renderModal(); + + expect(screen.getByText('Tiller Template (3).xlsx')).toBeInTheDocument(); + expect(screen.getByText('transaction-log.xlsx')).toBeInTheDocument(); + }); + + test('flags a new folder, a name clash and a skipped file', () => { + renderModal(); + + expect(screen.getByText('New folder')).toBeInTheDocument(); + expect(screen.getByText(/Name taken/)).toBeInTheDocument(); + expect(screen.getByText('1 file will not move')).toBeInTheDocument(); + expect(screen.getByText('File is no longer at its original location')).toBeInTheDocument(); + }); + + test('the move button commits and reports the count', () => { + const { onConfirm } = renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Move 3 files/ })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('hides file names and folders when privacy redaction is on', () => { + renderModal({ redactPaths: true }); + + expect(screen.queryByText('transaction-log.xlsx')).not.toBeInTheDocument(); + expect(screen.queryByText('C:\\Docs\\Finance')).not.toBeInTheDocument(); + expect(screen.getAllByText('Hidden file.xlsx').length).toBeGreaterThan(0); + }); + + test('renders nothing when closed', () => { + renderModal({ isOpen: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/test/e2e/full-pipeline.spec.js b/test/e2e/full-pipeline.spec.js index 6908421..1c40c86 100644 --- a/test/e2e/full-pipeline.spec.js +++ b/test/e2e/full-pipeline.spec.js @@ -150,7 +150,7 @@ test.describe('Full Pipeline — Analysis to Organize', () => { await expect(window.getByRole('heading', { name: /Organization Complete/i })).toBeVisible(); await expect(window.getByText('Session summary').first()).toBeVisible(); await expect(window.getByLabel('Organized file 1')).toContainText('pipeline-invoice.txt'); - await expect(window.getByRole('button', { name: /Start over/i })).toBeVisible(); + await expect(window.getByRole('button', { name: /Analyze more files/i })).toBeVisible(); }); test('should maintain file count when navigating between phases', async () => { diff --git a/test/ipcEventSchemas.test.js b/test/ipcEventSchemas.test.js index 032ebea..8090956 100644 --- a/test/ipcEventSchemas.test.js +++ b/test/ipcEventSchemas.test.js @@ -115,6 +115,26 @@ describe('IPC Event Schemas', () => { }); expect(result.valid).toBe(true); }); + + // UndoRedoService._notifyStateChanged broadcasts these bare reasons. When + // the schema only allowed undo/redo, strict mode blocked every + // record/remove/clear broadcast, so the toolbar missed history changes. + test.each(['record', 'remove', 'clear'])( + 'accepts the bare "%s" history broadcast from UndoRedoService', + (reason) => { + const result = validateEventPayload(IPC_CHANNELS.UNDO_REDO.STATE_CHANGED, { + action: reason + }); + expect(result.valid).toBe(true); + } + ); + + test('rejects an unknown undo-redo action', () => { + const result = validateEventPayload(IPC_CHANNELS.UNDO_REDO.STATE_CHANGED, { + action: 'teleport' + }); + expect(result.valid).toBe(false); + }); }); describe('operationProgressSchema', () => {