// @vitest-environment jsdom import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import React from 'react' vi.mock('../src/renderer/src/terminals', () => { let n = 0 return { Terminal: () => null, lid: () => ++n } }) import { App } from '../src/renderer/src/App' import { ProjectProvider } from '../src/renderer/src/project' beforeAll(() => { globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {} }) afterEach(() => { cleanup(); localStorage.clear() }) function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined { return Array.from(c.querySelectorAll(sel)).find((el) => el.textContent?.trim() === text) } async function openChanged(): Promise { const c = render().container const row = await waitFor(() => { const r = Array.from(c.querySelectorAll('.git-row')).find((el) => el.textContent?.includes('UserController.php')) if (!r) throw new Error('git not ready') return r }) fireEvent.click(row) await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy()) return c } describe('Editor view modes', () => { it('Actual mode is an editable buffer; Original is read-only', async () => { const c = await openChanged() fireEvent.click(find(c, '.seg button', 'Actual')!) await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy()) fireEvent.click(find(c, '.seg button', 'Original')!) await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull()) expect(c.querySelector('.editor .ln-row')).toBeTruthy() }) it('a git row opens the file in Actual, not in the full-screen Diff', async () => { const c = await openChanged() expect(c.querySelector('.split-overlay')).toBeNull() expect(find(c, '.seg button.on', 'Actual')).toBeTruthy() await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy()) }) it('Actual bands every changed line, over a buffer that stays editable', async () => { const c = await openChanged() // The mock rewrites six lines of UserController.php and removes none. await waitFor(() => expect(c.querySelectorAll('.ce-band')).toHaveLength(6)) expect(c.querySelector('.ce-gaprule')).toBeNull() expect(c.querySelectorAll('.ce-gutter .chg')).toHaveLength(6) expect(c.querySelector('.ln-sign')).toBeNull() expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).readOnly).toBe(false) }) it('the Diff segment opens the full-screen view, and Esc puts back the mode it covered', async () => { const c = await openChanged() fireEvent.click(find(c, '.seg button', 'Original')!) await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull()) fireEvent.click(find(c, '.seg button', 'Diff')!) await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy()) act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) }) await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull()) expect(find(c, '.seg button.on', 'Original')).toBeTruthy() }) it('hovering a band in Actual reveals the original, leaving the editor hides it', async () => { const c = await openChanged() const scroll = await waitFor(() => { const el = c.querySelector('.ce-scroll') if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready') return el }) // Line 30 is the first rewritten line. Unwrapped, its band is arithmetic: // a 10px top pad plus 20px per line, and the hit test reads clientY. fireEvent.mouseMove(scroll, { clientY: 10 + 29 * 20 + 5 }) const peek = await waitFor(() => { const p = c.querySelector('.peek') if (!p) throw new Error('peek not ready') return p }) const removed = Array.from(peek.querySelectorAll('.ln-row.del')) expect(removed).toHaveLength(1) expect(removed[0].textContent).toContain("'plan' => $user->plan,") fireEvent.mouseLeave(scroll) await waitFor(() => expect(c.querySelector('.peek')).toBeNull()) }) it('⌘M cycles Actual → Original → Diff, and nothing else', async () => { const c = await openChanged() const seen: string[] = [] for (let i = 0; i < 3; i++) { act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', metaKey: true })) }) await waitFor(() => { const on = c.querySelector('.seg button.on')?.textContent ?? '' if (seen[seen.length - 1] === on) throw new Error('mode not changed') seen.push(on) }) } // A tab opens in Actual, so the first ⌘M already leaves it. expect(seen).toEqual(['Original', 'Diff', 'Actual']) }) }) // Default config wraps Markdown only (editor.wordWrap: 'markdown'). A wrapped // buffer drops the fixed gutter column for one block per line, so the folded // rows carry no number. describe('Word wrap', () => { async function openTree(c: HTMLElement, name: string): Promise { const row = await waitFor(() => { const r = Array.from(c.querySelectorAll('.tree-row')).find((el) => el.textContent?.includes(name)) if (!r) throw new Error(`${name} not ready`) return r }) fireEvent.click(row) } it('folds long lines in Markdown, one numbered block per source line', async () => { const c = render().container await openTree(c, 'README.md') const edit = await waitFor(() => { const el = c.querySelector('.code-edit') if (!el) throw new Error('editor not ready') return el }) expect(edit.classList.contains('wrap')).toBe(true) expect(edit.querySelector('.ce-gutter')).toBeNull() const ta = edit.querySelector('.ce-ta')! expect(ta.getAttribute('wrap')).toBe('soft') expect(edit.querySelectorAll('.ce-line').length).toBe(ta.value.split('\n').length) }) it('leaves other file types on the scrolling gutter layout', async () => { const c = render().container await openTree(c, 'composer.json') const edit = await waitFor(() => { const el = c.querySelector('.code-edit') if (!el) throw new Error('editor not ready') return el }) expect(edit.classList.contains('wrap')).toBe(false) expect(edit.querySelector('.ce-gutter')).toBeTruthy() expect(edit.querySelector('.ce-line')).toBeNull() }) })