From 72cad959444bc125380113607de2d737d706fbd8 Mon Sep 17 00:00:00 2001 From: Aditya A P Date: Sat, 27 Dec 2025 01:05:05 +0530 Subject: [PATCH] test(chapterview): split critical flows + add inline edit --- .../ChapterView.critical-flows.test.tsx | 361 +----------------- .../ChapterView.inline-edit.test.tsx | 255 +++++++++++++ 2 files changed, 260 insertions(+), 356 deletions(-) create mode 100644 tests/integration/ChapterView.inline-edit.test.tsx 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'); + }); + }); +}); +