Files
helder/test/git-two-rows.test.tsx
Jonathan van Rij f6a551d7c4
Some checks failed
CI / check (push) Has been cancelled
some improvements
2026-09-11 11:01:57 +02:00

163 lines
6.8 KiB
TypeScript

// @vitest-environment jsdom
//
// A file can be staged and then edited again. Git calls that "MM": two rows,
// one per group. These tests drive the renderer with such a payload and check
// that Diff follows the row you clicked, while Original and Actual do not.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { 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'
import { installBridge, removeBridge } from './stub-bridge'
const HEAD = 'a\nb\nc\n'
const INDEX = 'a\nSTAGED\nc\n'
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
/** Exactly what git-service returns for porcelain "MM". */
function stubBridge(): void {
installBridge(
[
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
{ 'demo.txt': DISK },
)
}
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(stubBridge)
afterEach(() => {
cleanup()
localStorage.clear()
removeBridge()
})
/** Row text of the read-only view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor-wrap .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
/** The two panes of the full-screen Diff, as line text. */
function splitText(c: HTMLElement, pane: 'left' | 'right'): string[] {
return Array.from(c.querySelectorAll(`.split-pane.${pane} .ln-row`)).map((el) => (el.textContent ?? '').replace(/^\d*/, ''))
}
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
const head = heads.find((h) => h.textContent?.startsWith(label))!
const rows: HTMLElement[] = []
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
}
return rows
}
async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => {
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
})
return c
}
/** Open one git row, then lift its pair into the full-screen Diff. */
async function openSplit(c: HTMLElement, label: 'Staged Changes' | 'Changes'): Promise<void> {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
fireEvent.click(Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Diff')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
}
describe('a file that is staged and then edited again', () => {
it('shows up in both groups', async () => {
const c = await boot()
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
})
it('Diff on the staged row compares HEAD with the staged copy', async () => {
const c = await boot()
await openSplit(c, 'Staged Changes')
expect(splitText(c, 'left')).toEqual(['a', 'b', 'c'])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c'])
// The later edit is not part of what is staged, so neither side may show it.
expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING')
})
it('Diff on the unstaged row compares the staged copy with disk', async () => {
const c = await boot()
await openSplit(c, 'Changes')
// 'b' was already replaced before staging, so this half must not mention it.
expect(splitText(c, 'left')).toEqual(['a', 'STAGED', 'c', ''])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
})
it('the picked panel behind Actual always reaches back to HEAD', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll')
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
return el
})
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => {
const p = c.querySelector<HTMLElement>('.peek')
if (!p) throw new Error('peek not ready')
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
// A click anywhere off the code drops it.
fireEvent.mouseDown(document.body)
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
})
it('labels which pair the diff is comparing', async () => {
const c = await boot()
await openSplit(c, 'Staged Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged')
await openSplit(c, 'Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('staged → actual')
})
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
const c = await boot()
for (const label of ['Staged Changes', 'Changes'] as const) {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
fireEvent.click(original)
await waitFor(() => expect(viewText(c)).toContain('b'))
expect(viewText(c)).not.toContain('AFTER-STAGING')
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
fireEvent.click(actual)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
}
})
it('only the row you opened is highlighted', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
})
})