diff --git a/tests/integration/ChapterView.critical-flows.test.tsx b/tests/integration/ChapterView.critical-flows.test.tsx
index 2d9e892..37eb5be 100644
--- a/tests/integration/ChapterView.critical-flows.test.tsx
+++ b/tests/integration/ChapterView.critical-flows.test.tsx
@@ -1,17 +1,12 @@
/**
* ChapterView Critical User Flows
*
- * TEST-QUALITY: 7.5/10 (Target: High, user-facing)
+ * This file intentionally contains ONLY Flow #1 (diff markers).
*
- * Construct: "Users can view translations with diff markers, edit inline,
- * handle large chapters, and use media without layout collapse."
- *
- * Addresses audit gaps:
- * - ChapterView at 6.93% coverage (CRITICAL)
- * - Tests actual user interactions, not just rendering
- * - Decision-useful: blocks UI regressions users will notice
- *
- * These 4 flows cover 80% of daily user interactions.
+ * Other flows were split out to keep files small and to place layout/perf
+ * assertions into Playwright where the browser can provide real geometry:
+ * - Flow #2 (inline edit): `tests/integration/ChapterView.inline-edit.test.tsx`
+ * - Flow #3/#4 (perf + media/layout): `tests/e2e/chapterview.*.spec.ts`
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -231,349 +226,3 @@ describe('ChapterView: Critical Flow #1 - Diff Markers Visible & Positioned', ()
scrollSpy.mockRestore();
});
});
-
-describe('ChapterView: Critical Flow #2 - Inline Edit Preserves Markers', () => {
- it.skip('[Flow 2] editing text updates markers or shows stale indicator', async () => {
- const { useAppStore } = await import('../../store');
- const mockUpdateFn = vi.fn();
-
- vi.mocked(useAppStore).mockReturnValue({
- settings: {
- fontSize: 16,
- fontStyle: 'sans',
- lineHeight: 1.6,
- showDiffHeatmap: true,
- } as any,
- updateTranslationInline: mockUpdateFn,
- showNotification: vi.fn(),
- } as any);
-
- const translation = createMockTranslation(2);
-
- const { container } = render(
-
- );
-
- // Enable inline editing (double-click or button click)
- const firstParagraph = container.querySelector('[data-lf-type="text"]') as HTMLElement;
- fireEvent.doubleClick(firstParagraph);
-
- await waitFor(() => {
- // Should show edit mode (textarea or contentEditable)
- const editField = container.querySelector('textarea') ||
- container.querySelector('[contenteditable="true"]');
- expect(editField).toBeTruthy();
- });
-
- // Type new text
- const editField = container.querySelector('textarea') ||
- container.querySelector('[contenteditable="true"]') as HTMLElement;
-
- fireEvent.input(editField, { target: { value: 'Edited text content' } });
-
- // Save (press Ctrl+Enter or click save button)
- fireEvent.keyDown(editField, { key: 'Enter', ctrlKey: true });
-
- await waitFor(() => {
- // Should call update function
- expect(mockUpdateFn).toHaveBeenCalled();
-
- // Should show "stale" indicator or update markers
- const staleIndicator = container.querySelector('[data-diff-stale]') ||
- container.querySelector('.diff-stale-warning');
-
- // Either markers update OR stale warning appears
- const hasStaleWarning = staleIndicator !== null;
- const markersUpdated = mockUpdateFn.mock.calls.length > 0;
-
- expect(hasStaleWarning || markersUpdated).toBe(true);
- });
- });
-});
-
-describe('ChapterView: Critical Flow #3 - Large Chapter Performance', () => {
- it.skip('[Flow 3] renders 50KB chapter without catastrophic slowdown', async () => {
- // Generate large translation (~50KB)
- const largeParagraph = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100);
- const largeTranslation = (largeParagraph + '
').repeat(100); // ~50KB
-
- const startTime = Date.now();
-
- render(
-
- );
-
- const renderTime = Date.now() - startTime;
-
- // Performance guard: should render in under 3 seconds
- // (This catches O(n²) algorithms and React key issues)
- expect(renderTime).toBeLessThan(3000);
-
- // Should render content
- await waitFor(() => {
- const content = document.querySelector('[data-chapter-content]') ||
- document.querySelector('.chapter-view');
- expect(content).toBeTruthy();
- expect(content!.textContent!.length).toBeGreaterThan(1000);
- });
-
- // Layout should be valid (no NaN, no collapsed container)
- const container = document.querySelector('[data-chapter-content]') as HTMLElement;
- const height = container?.offsetHeight || 0;
- expect(height).toBeGreaterThan(0);
- expect(Number.isNaN(height)).toBe(false);
- });
-
- it.skip('[Flow 3] paragraph count matches expected for large chapter', async () => {
- const paragraphCount = 200;
- const translation = createMockTranslation(paragraphCount);
-
- render(
-
- );
-
- await waitFor(() => {
- // Count rendered paragraph nodes
- const paragraphs = document.querySelectorAll('[data-lf-type="text"]');
-
- // Should render approximately the right number
- // (within 10% tolerance for chunking algorithm)
- expect(paragraphs.length).toBeGreaterThan(paragraphCount * 0.9);
- expect(paragraphs.length).toBeLessThan(paragraphCount * 1.1);
- });
- });
-});
-
-describe('ChapterView: Critical Flow #4 - Illustration + Audio Coexistence', () => {
- it.skip('[Flow 4] renders illustration and audio player without layout collapse', async () => {
- const translation = 'The hero arrived. [ILLUSTRATION-1] He drew his sword.';
-
- render(
-
- );
-
- await waitFor(() => {
- // Should render illustration
- const illustration = screen.queryByRole('img') ||
- document.querySelector('[data-illustration]');
- expect(illustration).toBeTruthy();
-
- // Should render audio player
- const audioPlayer = screen.queryByRole('audio') ||
- document.querySelector('[data-audio-player]') ||
- document.querySelector('audio');
- expect(audioPlayer).toBeTruthy();
- });
-
- // Layout invariants: no collapse, no overflow
- const container = document.querySelector('[data-chapter-content]') as HTMLElement;
- const height = container?.offsetHeight || 0;
- expect(height).toBeGreaterThan(0);
- expect(Number.isNaN(height)).toBe(false);
-
- // Containers should have valid dimensions
- const illustration = document.querySelector('[data-illustration]') as HTMLElement;
- if (illustration) {
- const rect = illustration.getBoundingClientRect();
- expect(rect.width).toBeGreaterThan(0);
- expect(rect.height).toBeGreaterThan(0);
- }
- });
-
- it.skip('[Flow 4] toggling audio/illustration updates UI correctly', async () => {
- const translation = 'Test content [ILLUSTRATION-1]';
-
- const { rerender } = render(
-
- );
-
- // Initially no audio
- let audioPlayer = document.querySelector('audio');
- expect(audioPlayer).toBeNull();
-
- // Add audio
- rerender(
-
- );
-
- await waitFor(() => {
- audioPlayer = document.querySelector('audio');
- expect(audioPlayer).toBeTruthy();
- });
-
- // Layout should remain stable (no jumps)
- const container = document.querySelector('[data-chapter-content]') as HTMLElement;
- expect(container?.offsetHeight).toBeGreaterThan(0);
- });
-});
-
-/**
- * Implementation TODO (to raise score from 7.5 to 9.0):
- *
- * 1. Add accessibility tests:
- * - Keyboard navigation through markers
- * - Screen reader announcements
- * - Focus management in edit mode
- *
- * 2. Add error state tests:
- * - Illustration load failure
- * - Audio playback error
- * - Inline edit save failure
- *
- * 3. Add interaction sequences:
- * - Navigate markers → edit inline → save → markers update
- * - Play audio → scroll → audio continues
- *
- * 4. Add visual regression tests:
- * - Snapshot diff gutter layout
- * - Snapshot marker pip positioning
- *
- * Anti-Goodhart properties:
- * - Tests user-facing behavior, not implementation details
- * - Can't pass by mocking everything (layout checks are real)
- * - Performance test catches algorithmic issues
- * - Decision-useful: blocks regressions users will notice
- */
diff --git a/tests/integration/ChapterView.inline-edit.test.tsx b/tests/integration/ChapterView.inline-edit.test.tsx
new file mode 100644
index 0000000..202d9f0
--- /dev/null
+++ b/tests/integration/ChapterView.inline-edit.test.tsx
@@ -0,0 +1,255 @@
+/**
+ * ChapterView Flow #2 — Inline Edit (Vitest/jsdom)
+ *
+ * This test is intentionally scoped to the inline-edit UX and persistence wiring.
+ * Layout/perf validations live in Playwright (real browser).
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { create } from 'zustand';
+
+type ChapterLike = {
+ id: string;
+ title: string;
+ content: string;
+ prevUrl: string | null;
+ nextUrl: string | null;
+ fanTranslation: string | null;
+ translationResult: any;
+ feedback: any[];
+};
+
+const makeSelection = (element: HTMLElement) => {
+ const textNode = element.firstChild;
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
+ throw new Error('Expected target element to contain a text node for selection.');
+ }
+
+ const text = textNode.textContent || '';
+ const range = document.createRange();
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, Math.min(5, text.length));
+
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+};
+
+describe('ChapterView: Critical Flow #2 - Inline Edit', () => {
+ beforeEach(() => {
+ vi.resetModules();
+
+ if (!HTMLElement.prototype.scrollIntoView) {
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ writable: true,
+ value: vi.fn(),
+ });
+ }
+
+ // Avoid pulling in the full audio slice for this flow test.
+ vi.doMock('../../components/AudioPlayer', () => ({
+ __esModule: true,
+ default: () => null,
+ }));
+
+ // Ensure SelectionOverlay uses the touch sheet (no dependency on viewRef layout).
+ vi.doMock('../../hooks/useIsTouch', () => ({
+ useIsTouch: () => true,
+ }));
+
+ // Keep diffs deterministic: one marker so the gutter/pip UI is present.
+ vi.doMock('../../hooks/useDiffMarkers', () => ({
+ useDiffMarkers: () => ({
+ loading: false,
+ markers: [
+ {
+ chunkId: 'para-0-test',
+ colors: ['blue'],
+ reasons: ['fan-divergence'],
+ explanations: ['Test marker'],
+ aiRange: { start: 0, end: 10 },
+ position: 0,
+ },
+ ],
+ }),
+ }));
+ vi.doMock('../../hooks/useDiffNavigation', () => ({
+ useDiffNavigation: () => ({
+ currentIndex: 0,
+ totalMarkers: 0,
+ navigateToNext: vi.fn(),
+ navigateToPrevious: vi.fn(),
+ }),
+ }));
+ });
+
+ it('edits a selected chunk and persists via TranslationPersistenceService', async () => {
+ const showNotification = vi.fn();
+
+ const persistUpdatedTranslation = vi.fn(async (_chapterId: string, result: any) => ({
+ ...result,
+ id: 'translation-1',
+ version: 1,
+ chapterUrl: 'https://example.com/ch1',
+ stableId: _chapterId,
+ isActive: true,
+ }));
+
+ vi.doMock('../../services/translationPersistenceService', () => ({
+ TranslationPersistenceService: {
+ persistUpdatedTranslation,
+ createNewVersion: vi.fn(),
+ },
+ }));
+
+ const chapter: ChapterLike = {
+ id: 'ch-1',
+ title: 'Mock Chapter',
+ content: 'Raw content',
+ prevUrl: null,
+ nextUrl: null,
+ fanTranslation: null,
+ feedback: [],
+ translationResult: {
+ translatedTitle: 'Mock Chapter',
+ translation: 'This is paragraph 1 of the translation.
This is paragraph 2 of the translation.',
+ proposal: null,
+ footnotes: [],
+ suggestedIllustrations: [],
+ usageMetrics: {
+ totalTokens: 100,
+ promptTokens: 60,
+ completionTokens: 40,
+ estimatedCost: 0.001,
+ requestTime: 2,
+ provider: 'Gemini',
+ model: 'gemini-2.5-flash',
+ },
+ },
+ };
+
+ const updateChapterSpy = vi.fn();
+
+ vi.doMock('../../store', () => {
+ const useAppStore = create((set, get) => ({
+ currentChapterId: chapter.id,
+ chapters: new Map([[chapter.id, chapter]]),
+ urlIndex: new Map(),
+ rawUrlIndex: new Map(),
+ navigationHistory: [],
+
+ viewMode: 'english',
+ setViewMode: vi.fn(),
+
+ settings: {
+ provider: 'Gemini',
+ model: 'gemini-2.5-flash',
+ temperature: 0.7,
+ systemPrompt: 'Test prompt',
+ enableHtmlRepair: false,
+ showDiffHeatmap: true,
+ diffMarkerVisibility: {
+ fan: true,
+ rawLoss: true,
+ rawGain: true,
+ sensitivity: true,
+ stylistic: true,
+ },
+ fontSize: 16,
+ fontStyle: 'sans',
+ lineHeight: 1.6,
+ },
+
+ activePromptTemplate: null,
+ error: null,
+ isLoading: { fetching: false, translating: false },
+ hydratingChapters: {},
+ imageGenerationMetrics: null,
+ chapterAudioMap: new Map(),
+ showNotification,
+
+ handleNavigate: vi.fn(),
+ handleRetranslateCurrent: vi.fn(),
+ cancelTranslation: vi.fn(),
+ isTranslationActive: () => false,
+ shouldEnableRetranslation: () => false,
+ activeTranslations: {},
+
+ loadExistingImages: undefined,
+ generateIllustrationForSelection: vi.fn(),
+
+ updateChapter: (chapterId: string, patch: any) => {
+ updateChapterSpy(chapterId, patch);
+ set((state: any) => {
+ const next = new Map(state.chapters);
+ const existing = next.get(chapterId);
+ next.set(chapterId, { ...existing, ...patch });
+ return { chapters: next };
+ });
+ },
+ }));
+
+ return { useAppStore };
+ });
+
+ const ChapterView = (await import('../../components/ChapterView')).default;
+
+ render();
+
+ await waitFor(() => {
+ const firstChunk = document.querySelector('span[data-lf-type="text"][data-lf-chunk]');
+ expect(firstChunk).toBeTruthy();
+ });
+
+ const firstChunk = document.querySelector('span[data-lf-type="text"][data-lf-chunk]')!;
+ makeSelection(firstChunk);
+
+ // Update selection state used by SelectionOverlay.
+ fireEvent.mouseUp(document);
+
+ // Touch selection sheet should appear; click the edit button (✏️).
+ await waitFor(() => {
+ expect(screen.getByText('✏️')).toBeInTheDocument();
+ });
+
+ // Ensure selection still exists at click time for beginInlineEdit().
+ makeSelection(firstChunk);
+ fireEvent.click(screen.getByText('✏️'));
+
+ // Inline edit should enable contentEditable and show the toolbar.
+ const editable = await waitFor(() => {
+ const el = document.querySelector('[contenteditable="true"]');
+ expect(el).toBeTruthy();
+ return el!;
+ });
+
+ // Mutate the DOM text (save reads innerText from the element).
+ editable.innerText = 'Edited';
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => {
+ expect(persistUpdatedTranslation).toHaveBeenCalledTimes(1);
+ expect(updateChapterSpy).toHaveBeenCalledWith(
+ chapter.id,
+ expect.objectContaining({
+ translationResult: expect.anything(),
+ translationSettingsSnapshot: expect.anything(),
+ })
+ );
+ });
+
+ // Should not have shown selection validation warnings.
+ expect(showNotification).not.toHaveBeenCalledWith(
+ 'Select text within the translation to edit.',
+ expect.anything()
+ );
+
+ // After re-render, edited text should be present in the rendered translation.
+ await waitFor(() => {
+ expect(document.body.textContent).toContain('Edited');
+ });
+ });
+});
+