Files
kotej/web/tests/patch.test.ts
T
alexandrev-tibco 83d61df13b Exportar la comparación en un formato pensado para pegar a un LLM
Pedido: poder llevar la comparativa a una sesión abierta con un modelo para que
la use. Eso, y no un informe para imprimir, decide el formato: los ficheros
iguales se resumen en una linea en vez de listarse, los binarios solo se
mencionan, y las diferencias van en diff unificado, que los modelos leen de
forma nativa. Lo que se omite se dice en voz alta — un informe que se deja la
mitad en silencio es peor que ninguno, porque se lee como completo.

Las cabeceras `--- a/ +++ b/` cuestan dos lineas y convierten cada bloque en un
parche de verdad, asi que el modelo puede devolverlo por `git apply` en vez de
reescribir el cambio a mano. Que lo sea de verdad esta comprobado ejecutando
git sobre la salida en 10 escenarios (sin salto final, insercion al principio,
borrado al final...); dos fallos aparecieron asi: interlineaba `-` y `+` en vez
de agrupar, y contaba la linea fantasma que deja el ultimo `\n`.

Implementado en los dos motores (Swift y TypeScript) con la misma semantica, y
verificado que producen el mismo texto **byte a byte** para un mismo caso.
Tambien hay salida JSON, para alimentar una herramienta en vez de una charla.

UI en las dos apps: copiar al portapapeles primero (que es el gesto real), y
guardar/descargar despues. En web, si el portapapeles se niega, se descarga y
se avisa: un fallo silencioso ahi acaba en pegar contenido viejo sin saberlo.

De paso, un bug que esto destapó: el menu contextual de la web se cerraba en
`pointerdown`, quitando los botones antes de que su click llegara — ninguna
accion del menu funcionaba (tampoco "Set as base" ni "Expand all"). El smoke
test nunca habia pulsado una; ahora si.

Closes #9

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hEAYuHRKMYz9sSa9zmbzz
2026-08-02 09:19:59 +02:00

77 lines
3.7 KiB
TypeScript

/** Hands the export to git itself: the only real proof the patch is valid. */
import { it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { scanFiles, compareTrees } from '../src/engine/tree';
import { exportMarkdown } from '../src/engine/export';
const f = (p: string, b: string) =>
({ file: new File([new TextEncoder().encode(b)], p.split('/').pop()!), relativePath: p });
/** Builds the export for one file pair and returns just the patch body. */
async function patchFor(before: string, after: string): Promise<string> {
const left = await scanFiles([f('src/app.txt', before)], 'v1');
const right = await scanFiles([f('src/app.txt', after)], 'v2');
const root = await compareTrees(left, right);
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
return markdown.split('```diff')[1].split('```')[0].trimStart();
}
/** Applies the patch with real git and returns the resulting file. */
function applyWithGit(before: string, patch: string): string {
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
writeFileSync(join(dir, 'src/app.txt'), before);
writeFileSync(join(dir, 'change.patch'), patch);
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
return readFileSync(join(dir, 'src/app.txt'), 'utf8');
}
const CASES: Array<[string, string, string]> = [
['change in the middle', 'uno\ndos\ntres\n', 'uno\nDOS\ntres\n'],
['no trailing newline', 'uno\ndos\ntres', 'uno\nDOS\ntres'],
['insertion at the very top', 'uno\ndos\n', 'cero\nuno\ndos\n'],
['deletion at the very top', 'cero\nuno\ndos\n', 'uno\ndos\n'],
['append at the end', 'uno\ndos\n', 'uno\ndos\ntres\n'],
['deletion at the end', 'uno\ndos\ntres\n', 'uno\ndos\n'],
['blank line kept at the end', 'uno\n\n', 'DOS\n\n'],
['two changes far apart',
Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n') + '\n',
Array.from({ length: 40 }, (_, i) => (i === 2 || i === 35 ? `LINE ${i}` : `line ${i}`)).join('\n') + '\n'],
['everything replaced', 'a\nb\nc\n', 'x\ny\nz\n'],
];
for (const [label, before, after] of CASES) {
it(`git apply handles: ${label}`, async () => {
expect(applyWithGit(before, await patchFor(before, after))).toBe(after);
});
}
it('git apply accepts the exported diff and reproduces side B', async () => {
const beforeBody = 'uno\ndos\ntres\ncuatro\ncinco\nseis\nsiete\nocho\n';
const afterBody = 'uno\nDOS cambiado\ntres\ncuatro\ncinco\nseis\nSIETE\nocho\n';
const left = await scanFiles([f('src/app.txt', beforeBody)], 'v1');
const right = await scanFiles([f('src/app.txt', afterBody)], 'v2');
const root = await compareTrees(left, right);
const markdown = await exportMarkdown({ root, leftName: 'v1', rightName: 'v2', mode: null });
const patch = markdown.split('```diff')[1].split('```')[0].trimStart();
const dir = mkdtempSync(join(tmpdir(), 'kotej-patch-'));
mkdirSync(dirname(join(dir, 'src/app.txt')), { recursive: true });
writeFileSync(join(dir, 'src/app.txt'), beforeBody);
writeFileSync(join(dir, 'change.patch'), patch);
execFileSync('git', ['init', '-q'], { cwd: dir });
// --check first: git refuses a malformed or non-applying patch outright.
execFileSync('git', ['apply', '--check', 'change.patch'], { cwd: dir });
execFileSync('git', ['apply', 'change.patch'], { cwd: dir });
expect(readFileSync(join(dir, 'src/app.txt'), 'utf8')).toBe(afterBody);
});