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")) } }