|
| 1 | +"""Exercise the checker against real, temporary Git histories; no network access.""" |
| 2 | + |
| 3 | +import contextlib |
| 4 | +import copy |
| 5 | +import io |
| 6 | +import json |
| 7 | +from pathlib import Path |
| 8 | +import sys |
| 9 | +import tempfile |
| 10 | +import unittest |
| 11 | + |
| 12 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| 13 | +import upstream |
| 14 | + |
| 15 | + |
| 16 | +class UpstreamTest(unittest.TestCase): |
| 17 | + def setUp(self): |
| 18 | + self.temp = tempfile.TemporaryDirectory() |
| 19 | + self.addCleanup(self.temp.cleanup) |
| 20 | + self.repo = Path(self.temp.name) |
| 21 | + upstream.git(self.repo, "init", "--quiet") |
| 22 | + upstream.git(self.repo, "config", "user.email", "test@example.invalid") |
| 23 | + upstream.git(self.repo, "config", "user.name", "Upstream tests") |
| 24 | + self.path = self.repo / "src" / "Example.lean" |
| 25 | + self.path.parent.mkdir() |
| 26 | + self.original = ( |
| 27 | + "namespace Example\n\n" |
| 28 | + "def foo (x : Nat) : Nat :=\n" |
| 29 | + " x + 1\n\n" |
| 30 | + "def other : Nat := 10\n\n" |
| 31 | + "end Example\n" |
| 32 | + ) |
| 33 | + self.path.write_text(self.original) |
| 34 | + self.before = self.commit() |
| 35 | + self.metadata = {"schema": 1, "commit": self.before, "entries": [{ |
| 36 | + "local": "Adapted.foo", "local_file": "RandomDo/Example.lean", |
| 37 | + "upstream": "Example.foo", "path": "src/Example.lean", "start": 3, "end": 4, |
| 38 | + }]} |
| 39 | + self.baseline = upstream.record(None, self.metadata, self.source(), None, "Adapted to m") |
| 40 | + |
| 41 | + def source(self): |
| 42 | + return upstream.Source(self.repo) |
| 43 | + |
| 44 | + def commit(self): |
| 45 | + upstream.git(self.repo, "add", ".") |
| 46 | + upstream.git(self.repo, "commit", "--quiet", "--no-gpg-sign", "-m", "fixture") |
| 47 | + return upstream.git(self.repo, "rev-parse", "HEAD").strip() |
| 48 | + |
| 49 | + def change(self, text): |
| 50 | + self.path.write_text(text) |
| 51 | + return self.commit() |
| 52 | + |
| 53 | + def report(self, target): |
| 54 | + return upstream.check(self.baseline, self.source(), target) |
| 55 | + |
| 56 | + def test_unchanged_and_unrelated_edits(self): |
| 57 | + self.assertEqual(self.report(self.before)["changes"], []) |
| 58 | + target = self.change(self.original.replace("10", "20")) |
| 59 | + self.assertEqual(self.report(target)["changes"], []) |
| 60 | + |
| 61 | + def test_unrelated_line_insertion_does_not_shift_the_baseline(self): |
| 62 | + target = self.change("-- new module comment\n" + self.original) |
| 63 | + self.assertEqual(self.report(target)["changes"], []) |
| 64 | + |
| 65 | + def test_statement_and_body_changes(self): |
| 66 | + for old, new in [("x : Nat", "x : Int"), ("x + 1", "x + 2")]: |
| 67 | + with self.subTest(new=new): |
| 68 | + target = self.change(self.original.replace(old, new)) |
| 69 | + report = self.report(target) |
| 70 | + self.assertEqual(len(report["changes"]), 1) |
| 71 | + self.assertIn(new, report["changes"][0]["diff"]) |
| 72 | + |
| 73 | + def test_insertions_at_both_boundaries_are_conservatively_reported(self): |
| 74 | + for text in [self.original.replace("def foo", "@[inline]\ndef foo"), |
| 75 | + self.original.replace(" x + 1\n", " x + 1\n + 2\n")]: |
| 76 | + with self.subTest(text=text): |
| 77 | + self.assertEqual(len(self.report(self.change(text))["changes"]), 1) |
| 78 | + |
| 79 | + def test_deleted_declaration_and_renamed_declaration(self): |
| 80 | + target = self.change(self.original.replace("def foo (x : Nat) : Nat :=\n x + 1\n", "")) |
| 81 | + self.assertTrue(self.report(target)["changes"]) |
| 82 | + target = self.change(self.original.replace("def foo", "def renamed")) |
| 83 | + self.assertTrue(self.report(target)["changes"]) |
| 84 | + |
| 85 | + def test_deleted_or_moved_file_is_reported(self): |
| 86 | + self.path.rename(self.path.with_name("Moved.lean")) |
| 87 | + self.assertTrue(self.report(self.commit())["changes"]) |
| 88 | + |
| 89 | + def test_corrupted_baseline_is_an_error(self): |
| 90 | + self.baseline["entries"][0]["sha256"] = "0" * 64 |
| 91 | + with self.assertRaisesRegex(upstream.CheckError, "hash mismatch"): |
| 92 | + self.report(self.before) |
| 93 | + |
| 94 | + def test_missing_revision_is_an_error(self): |
| 95 | + with self.assertRaises(upstream.CheckError): |
| 96 | + self.report("no-such-revision") |
| 97 | + |
| 98 | + def test_source_range_and_identity_are_validated(self): |
| 99 | + for key, value in [("start", 0), ("end", -1), ("path", "../outside.lean")]: |
| 100 | + baseline = copy.deepcopy(self.baseline) |
| 101 | + baseline["entries"][0][key] = value |
| 102 | + with self.subTest(key=key), self.assertRaises(upstream.CheckError): |
| 103 | + upstream.check(baseline, self.source(), self.before) |
| 104 | + baseline = copy.deepcopy(self.baseline) |
| 105 | + baseline["entries"].append(baseline["entries"][0]) |
| 106 | + with self.assertRaisesRegex(upstream.CheckError, "Duplicate"): |
| 107 | + upstream.entries(baseline, baseline=True) |
| 108 | + |
| 109 | + def test_new_removed_and_moved_annotations_require_review(self): |
| 110 | + upstream.validate_mappings(self.baseline, self.metadata) |
| 111 | + for key, value in [("local", "Adapted.new"), ("upstream", "Example.new"), |
| 112 | + ("local_file", "RandomDo/New.lean")]: |
| 113 | + metadata = copy.deepcopy(self.metadata) |
| 114 | + metadata["entries"][0][key] = value |
| 115 | + with self.subTest(key=key), self.assertRaises(upstream.CheckError): |
| 116 | + upstream.validate_mappings(self.baseline, metadata) |
| 117 | + |
| 118 | + def test_range_must_match_exported_metadata_at_the_same_commit(self): |
| 119 | + baseline = copy.deepcopy(self.baseline) |
| 120 | + baseline["entries"][0]["start"] = 4 |
| 121 | + with self.assertRaisesRegex(upstream.CheckError, "range disagrees"): |
| 122 | + upstream.validate_mappings(baseline, self.metadata) |
| 123 | + |
| 124 | + def test_invalid_commit_types_are_check_errors(self): |
| 125 | + for field in ("commit", "sha256"): |
| 126 | + baseline = copy.deepcopy(self.baseline) |
| 127 | + baseline["entries"][0][field] = None |
| 128 | + with self.subTest(field=field), self.assertRaises(upstream.CheckError): |
| 129 | + upstream.entries(baseline, baseline=True) |
| 130 | + |
| 131 | + def test_partial_review_preserves_other_baselines(self): |
| 132 | + metadata = copy.deepcopy(self.metadata) |
| 133 | + metadata["entries"].append({**metadata["entries"][0], "local": "Adapted.second"}) |
| 134 | + baseline = upstream.record(None, metadata, self.source(), None, "Initial review") |
| 135 | + metadata["commit"] = self.change(self.original.replace("x + 1", "x + 2")) |
| 136 | + reviewed = upstream.record(baseline, metadata, self.source(), ["Adapted.foo"], "Ported fix") |
| 137 | + by_name = {e["local"]: e for e in reviewed["entries"]} |
| 138 | + self.assertEqual(by_name["Adapted.foo"]["commit"], metadata["commit"]) |
| 139 | + self.assertEqual(by_name["Adapted.second"]["commit"], self.before) |
| 140 | + report = upstream.check(reviewed, self.source(), metadata["commit"]) |
| 141 | + self.assertEqual([c["local"] for c in report["changes"]], ["Adapted.second"]) |
| 142 | + |
| 143 | + def test_no_automatic_baseline_update_and_exit_codes(self): |
| 144 | + lock = self.repo / "baseline.json" |
| 145 | + lock.write_text(json.dumps(self.baseline)) |
| 146 | + original = lock.read_bytes() |
| 147 | + target = self.change(self.original.replace("x + 1", "x + 2")) |
| 148 | + args = ["--baseline", str(lock), "--repo", str(self.repo), "check", "--target", target] |
| 149 | + with contextlib.redirect_stdout(io.StringIO()): |
| 150 | + self.assertEqual(upstream.main(args), 1) |
| 151 | + self.assertEqual(upstream.main(args + ["--advisory"]), 0) |
| 152 | + with contextlib.redirect_stderr(io.StringIO()): |
| 153 | + self.assertEqual(upstream.main(args[:-1] + ["missing"] + ["--advisory"]), 2) |
| 154 | + self.assertEqual(lock.read_bytes(), original) |
| 155 | + |
| 156 | + def test_notification_fingerprint_ignores_unrelated_commits(self): |
| 157 | + changed = self.original.replace("x + 1", "x + 2") |
| 158 | + first = self.report(self.change(changed)) |
| 159 | + second = self.report(self.change("-- unrelated\n" + changed)) |
| 160 | + self.assertNotEqual(first["target"], second["target"]) |
| 161 | + self.assertEqual(first["fingerprint"], second["fingerprint"]) |
| 162 | + |
| 163 | + def test_report_includes_mapping_review_note_and_upstream_diff(self): |
| 164 | + report = self.report(self.change(self.original.replace("x + 1", "x + 2"))) |
| 165 | + text = upstream.markdown(report) |
| 166 | + for expected in ["Adapted.foo", "Example.foo", "Adapted to m", "- x + 1", "+ x + 2"]: |
| 167 | + self.assertIn(expected, text) |
| 168 | + |
| 169 | + |
| 170 | +if __name__ == "__main__": |
| 171 | + unittest.main() |
0 commit comments