83d61df13b
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
247 lines
12 KiB
Swift
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"))
|
|
}
|
|
}
|