Files
kotej/Tests/KotejEngineTests/ComparisonExportTests.swift
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

247 lines
12 KiB
Swift

import XCTest
@testable import KotejEngine
/// The export exists to be pasted into a conversation with a model, so these
/// check the things that would mislead one: which side is which, changes that go
/// missing, and truncation that isn't declared.
///
/// They mirror `web/tests/export.test.ts`; the two engines must produce the same
/// text, so a change here without a change there is a bug.
final class ComparisonExportTests: XCTestCase {
private var root: URL!
override func setUpWithError() throws {
root = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("kotej-export-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: root)
}
/// Builds two folders from `[path: contents]` and compares them.
private func comparison(left: [String: String], right: [String: String]) throws -> DiffNode {
let leftURL = try write(left, into: "v1")
let rightURL = try write(right, into: "v2")
let leftTree = try TreeScanner.scan(leftURL)
let rightTree = try TreeScanner.scan(rightURL)
return DirectoryComparer.compare(left: leftTree, right: rightTree)
}
private func write(_ files: [String: String], into name: String) throws -> URL {
let base = root.appendingPathComponent(name)
for (path, contents) in files {
let url = base.appendingPathComponent(path)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try contents.write(to: url, atomically: true, encoding: .utf8)
}
return base
}
private func markdown(left: [String: String], right: [String: String],
options: ComparisonExport.Options = .init()) throws -> String {
let node = try comparison(left: left, right: right)
return ComparisonExport.markdown(root: node, leftName: "v1", rightName: "v2",
mode: nil, options: options)
}
// MARK: Markdown
func testStatesWhichSideIsWhich() throws {
let text = try markdown(left: ["a.txt": "uno\n"], right: ["a.txt": "dos\n"])
XCTAssertTrue(text.contains("**A** is the left side"))
XCTAssertTrue(text.contains("`-` is A and `+` is B"))
}
func testReportsEveryCategory() throws {
let text = try markdown(
left: ["same.txt": "x\n", "changed.txt": "uno\n", "gone.txt": "y\n"],
right: ["same.txt": "x\n", "changed.txt": "dos\n", "new.txt": "z\n"])
XCTAssertTrue(text.contains("1 same, 1 different, 1 only in A, 1 only in B"))
XCTAssertTrue(text.contains("gone.txt"))
XCTAssertTrue(text.contains("new.txt"))
XCTAssertTrue(text.contains("### `changed.txt`"))
}
func testEmitsUnifiedDiff() throws {
let text = try markdown(left: ["f.txt": "uno\ndos\ntres\n"],
right: ["f.txt": "uno\nDOS\ntres\n"])
XCTAssertTrue(text.contains("```diff"))
XCTAssertTrue(text.contains("--- a/f.txt"))
XCTAssertTrue(text.contains("-dos"))
XCTAssertTrue(text.contains("+DOS"))
XCTAssertTrue(text.contains(" uno"), "context lines should be kept")
}
func testSummarisesIdenticalFilesInsteadOfListingThem() throws {
var files: [String: String] = [:]
for index in 0..<30 { files["f\(index).txt"] = "igual\n" }
let text = try markdown(left: files, right: files)
XCTAssertTrue(text.contains("30 files matched and are not listed"))
XCTAssertFalse(text.contains("f17.txt"))
}
func testListsIdenticalFilesWhenAsked() throws {
let text = try markdown(left: ["f.txt": "x\n"], right: ["f.txt": "x\n"],
options: .init(includeIdentical: true))
XCTAssertTrue(text.contains("- `f.txt`"))
}
func testExplainsEquivalenceRatherThanCallingItIdentical() throws {
let text = try markdown(left: ["c.json": "{\"a\":1,\"b\":2}"],
right: ["c.json": "{\n \"b\": 2,\n \"a\": 1\n}"])
XCTAssertTrue(text.contains("equivalent rather than byte-identical"))
}
func testDeclaresTruncatedLines() throws {
let before = (0..<200).map { "line \($0)" }.joined(separator: "\n")
let after = (0..<200).map { "LINE \($0)" }.joined(separator: "\n")
let text = try markdown(left: ["big.txt": before], right: ["big.txt": after],
options: .init(maxLinesPerFile: 20))
XCTAssertTrue(text.contains("further diff lines omitted"))
}
func testDeclaresTruncatedFiles() throws {
var left: [String: String] = [:], right: [String: String] = [:]
for index in 0..<10 {
left["f\(index).txt"] = "uno\n"
right["f\(index).txt"] = "dos\n"
}
let text = try markdown(left: left, right: right, options: .init(maxFiles: 3))
XCTAssertTrue(text.contains("7 more differing files, listed without their diffs"))
}
func testNamesTheModeThatSettledTheFile() throws {
let text = try markdown(left: ["f.txt": "uno\n"], right: ["f.txt": "dos\n"])
XCTAssertTrue(text.contains("Compared as text"))
}
func testDistinguishesCommentOnlyChange() throws {
let text = try markdown(left: ["App.swift": "let total = 3 // vieja\n"],
right: ["App.swift": "let total = 3 // nueva\n"])
XCTAssertTrue(text.contains("comment-only"))
}
func testHasNoRunsOfBlankLines() throws {
let text = try markdown(left: ["a.txt": "uno\n"], right: ["a.txt": "dos\n"])
XCTAssertFalse(text.contains("\n\n\n"))
XCTAssertTrue(text.hasSuffix("\n"))
}
// MARK: Unified diff
func testCollapsesUntouchedStretchesIntoHunks() throws {
let before = (["a"] + (0..<40).map { "x\($0)" } + ["b"]).joined(separator: "\n")
let after = (["A"] + (0..<40).map { "x\($0)" } + ["B"]).joined(separator: "\n")
let rows = TextDiff.rows(left: before, right: after, syntax: nil)
let diff = ComparisonExport.unified(rows: rows, leftEndsWithNewline: false,
rightEndsWithNewline: false)
let headers = diff.text.split(separator: "\n", omittingEmptySubsequences: false)
.filter { $0.hasPrefix("@@") }
XCTAssertEqual(headers.count, 2)
XCTAssertFalse(diff.text.contains("x20"))
}
func testGroupsRemovalsBeforeAdditions() throws {
let rows = TextDiff.rows(left: "uno\ndos\ntres\ncuatro",
right: "UNO\nDOS\ntres\ncuatro", syntax: nil)
let diff = ComparisonExport.unified(rows: rows, leftEndsWithNewline: false,
rightEndsWithNewline: false)
let body = diff.text.split(separator: "\n").filter { !$0.hasPrefix("@@") }
// -uno -dos +UNO +DOS, never -uno +UNO -dos +DOS
XCTAssertEqual(Array(body.prefix(4)), ["-uno", "-dos", "+UNO", "+DOS"])
}
func testEmptyWhenFilesMatch() throws {
let rows = TextDiff.rows(left: "uno\ndos", right: "uno\ndos", syntax: nil)
let diff = ComparisonExport.unified(rows: rows)
XCTAssertEqual(diff.text, "")
XCTAssertEqual(diff.omitted, 0)
}
// MARK: The strongest check hand it to git
func testGitAppliesTheExportedPatch() throws {
let cases: [(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"),
("everything replaced", "a\nb\nc\n", "x\ny\nz\n"),
]
for (label, before, after) in cases {
let node = try comparison(left: ["src/app.txt": before], right: ["src/app.txt": after])
let text = ComparisonExport.markdown(root: node, leftName: "v1", rightName: "v2", mode: nil)
guard let patch = text.components(separatedBy: "```diff").dropFirst().first?
.components(separatedBy: "```").first?
.drop(while: { $0 == "\n" }) else {
XCTFail("no patch block for: \(label)")
continue
}
XCTAssertEqual(try applyWithGit(before: before, patch: String(patch)), after,
"git could not apply the patch for: \(label)")
}
}
/// Applies the patch with real git and returns the resulting file.
private func applyWithGit(before: String, patch: String) throws -> String {
let dir = root.appendingPathComponent("git-\(UUID().uuidString)")
let file = dir.appendingPathComponent("src/app.txt")
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(),
withIntermediateDirectories: true)
try before.write(to: file, atomically: true, encoding: .utf8)
try patch.write(to: dir.appendingPathComponent("change.patch"),
atomically: true, encoding: .utf8)
try run(["init", "-q"], in: dir)
// --check first: git refuses a malformed or non-applying patch outright.
try run(["apply", "--check", "change.patch"], in: dir)
try run(["apply", "change.patch"], in: dir)
return try String(contentsOf: file, encoding: .utf8)
}
private func run(_ arguments: [String], in directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = arguments
process.currentDirectoryURL = directory
let errors = Pipe()
process.standardError = errors
process.standardOutput = Pipe()
try process.run()
let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
process.waitUntilExit()
if process.terminationStatus != 0 {
throw NSError(domain: "git", code: Int(process.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: "git \(arguments.joined(separator: " ")): \(message)"])
}
}
// MARK: JSON
func testJSONCarriesTotalsAndEntries() throws {
let node = try comparison(left: ["same.txt": "x\n", "changed.txt": "uno\n", "gone.txt": "y\n"],
right: ["same.txt": "x\n", "changed.txt": "dos\n"])
let text = ComparisonExport.json(root: node, leftName: "v1", rightName: "v2", mode: nil)
let parsed = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]
XCTAssertEqual(parsed?["tool"] as? String, "Kotej")
let totals = parsed?["totals"] as? [String: Int]
XCTAssertEqual(totals?["same"], 1)
XCTAssertEqual(totals?["different"], 1)
XCTAssertEqual(totals?["onlyLeft"], 1)
let files = parsed?["files"] as? [[String: Any]]
let changed = files?.first { $0["path"] as? String == "changed.txt" }
XCTAssertEqual(changed?["status"] as? String, "different")
XCTAssertTrue((changed?["diff"] as? String ?? "").contains("-uno"))
}
}