From b64e222c1b10ec2babe22bf65ec18dfd9d55ac05 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:06:08 -0500 Subject: [PATCH 1/2] fix(ipc): undo history broadcasts are no longer blocked by the event schema UndoRedoService._notifyStateChanged sends { action: 'record' | 'remove' | 'clear' } on UNDO_REDO.STATE_CHANGED, but the event schema only allowed 'undo' | 'redo'. Strict-mode safeSend blocked every such broadcast, so the undo toolbar missed history changes made elsewhere (logged on every organize move). The service's own test mocks safeSend, so it never ran the payload through the schema; the schema test now covers each reason. --- src/shared/ipcEventSchemas.js | 6 ++++-- test/ipcEventSchemas.test.js | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/shared/ipcEventSchemas.js b/src/shared/ipcEventSchemas.js index 38fc984..253fc01 100644 --- a/src/shared/ipcEventSchemas.js +++ b/src/shared/ipcEventSchemas.js @@ -322,10 +322,12 @@ const schemas = z /** * Undo/Redo State Changed Event - * Emitted after undo/redo operations to notify UI of state changes + * Emitted after undo/redo operations to notify UI of state changes, and by + * UndoRedoService._notifyStateChanged whenever history changes for another + * reason (an action recorded, removed, or the history cleared). */ const undoRedoStateChangedSchema = z.object({ - action: z.enum(['undo', 'redo']), + action: z.enum(['undo', 'redo', 'record', 'remove', 'clear']), result: z .object({ success: z.boolean(), 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', () => { From 64ff8d3feaa48cc8a0c0d42423cf99b9213e3bf4 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:06:19 -0500 Subject: [PATCH 2/2] fix(ui): clearer next steps on Complete, a real Organize preview, honest Discover count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete: "Start over" becomes "Analyze more files", which clears the finished batch and lands on Discover instead of Welcome. The open-folder button names the folder it opens; each row shows the new name and folder and can reveal the file. A failed open now shows an error (the handler returns { success: false } rather than throwing, so it was silent). - Organize: Preview opens a dialog listing each file under its destination folder with its new name, new folders, name clashes and skipped files, instead of a count-only toast. - Discover: the selection badge said "N files ready" for files not yet analyzed, and grew as files were added mid-run. It now reads "N files added · M analyzed" until every file is analyzed. --- .../organize/OrganizePreviewModal.jsx | 191 ++++++++++++++++++ src/renderer/phases/CompletePhase.jsx | 118 +++++++---- src/renderer/phases/DiscoverPhase.jsx | 15 +- src/renderer/phases/OrganizePhase.jsx | 33 +-- test/components/CompletePhase.test.js | 86 +++++++- test/components/DiscoverPhase.test.js | 26 +++ test/components/OrganizePreviewModal.test.js | 115 +++++++++++ test/e2e/full-pipeline.spec.js | 2 +- 8 files changed, 522 insertions(+), 64 deletions(-) create mode 100644 src/renderer/components/organize/OrganizePreviewModal.jsx create mode 100644 test/components/OrganizePreviewModal.test.js 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 () => {