Viewing files at commit 538751fb07e6Commit detailsView latest

Browse files
tests/test_scale_suite.py 532 lines · 28.6 KB · python
1
"""Instrument tests for suites/scale (generator, sizer, tiers, op oracles, rows).
2
3
Hermetic: no Oak binary and no network. Git-backed tests use the resolved
4
real git and skip when it is absent. Oak-side oracles are exercised with
5
canned subject output (a stub timed runner), so every op's untimed oracle is
6
tested for both a passing and a failing observation.
7
"""
8
9
from __future__ import annotations
10
11
import hashlib
12
import io
13
import json
14
import re
15
import shutil
16
import subprocess
17
import sys
18
import tempfile
19
import unittest
20
from pathlib import Path
21
from types import SimpleNamespace
22
23
ROOT = Path(__file__).resolve().parents[1]
24
sys.path.insert(0, str(ROOT / "suites" / "scale"))
25
sys.path.insert(0, str(ROOT / "scripts"))
26
27
from oakbench.execution import TimedCapture, run_timed  # noqa: E402
28
from oakbench.rows import validate_rows  # noqa: E402
29
from scalesuite import generator as gen  # noqa: E402
30
from scalesuite import ops as ops_mod  # noqa: E402
31
from scalesuite import runner as runner_mod  # noqa: E402
32
from scalesuite import sizer as sz  # noqa: E402
33
from scalesuite import tiers as tiers_mod  # noqa: E402
34
from scalesuite import workspace as ws  # noqa: E402
35
36
try:
37
    from oakbench.subjects import which_measured_git
38
    GIT = which_measured_git()
39
except Exception:  # pragma: no cover
40
    GIT = shutil.which("git")
41
42
SMALL = gen.GenSpec(name="t", seed="unit-seed", files=60, file_bytes=96, depth=4, fanout=4, commits=30,
43
                    touches_per_commit=3, branches=4, branch_commit_files=2, tags=3, binaries=1,
44
                    binary_bytes=(2 << 20) + 17, binary_revisions=2, ignore_patterns=("build/",),
45
                    conflict_files=3)
46
47
48
def stream_sha(spec: gen.GenSpec) -> str:
49
    buffer = io.BytesIO()
50
    gen.write_stream(spec, buffer)
51
    return hashlib.sha256(buffer.getvalue()).hexdigest()
52
53
54
def git(*args: str, cwd: Path) -> str:
55
    return subprocess.run([GIT, *args], cwd=cwd, check=True, capture_output=True, text=True,
56
                          env=gen.git_env()).stdout
57
58
59
class GeneratorDeterminismTest(unittest.TestCase):
60
    def test_same_spec_same_stream(self) -> None:
61
        self.assertEqual(stream_sha(SMALL), stream_sha(SMALL))
62
63
    def test_seed_changes_stream_and_spec_hash(self) -> None:
64
        other = gen.GenSpec(**{**SMALL.to_json(), "seed": "other-seed", "ignore_patterns": ("build/",)})
65
        self.assertNotEqual(stream_sha(SMALL), stream_sha(other))
66
        self.assertNotEqual(SMALL.spec_sha256(), other.spec_sha256())
67
68
    def test_spec_hash_is_stable_text(self) -> None:
69
        self.assertEqual(SMALL.spec_sha256(), gen.GenSpec(**{**SMALL.to_json(), "ignore_patterns": ("build/",)}).spec_sha256())
70
        self.assertRegex(SMALL.spec_sha256(), r"^[0-9a-f]{64}$")
71
72
    def test_validation(self) -> None:
73
        with self.assertRaises(ValueError):
74
            gen.GenSpec(name="x", seed="s", files=0).validate()
75
        with self.assertRaises(ValueError):
76
            gen.GenSpec(name="x", seed="s", binary_revisions=2, commits=3).validate()
77
        with self.assertRaises(ValueError):
78
            gen.GenSpec(name="x", seed="s", file_bytes=8).validate()
79
80
    def test_every_path_has_exact_depth_and_is_unique(self) -> None:
81
        for spec in (SMALL, gen.GenSpec(name="d", seed="s", files=5000, depth=40, fanout=8),
82
                     gen.GenSpec(name="w", seed="s", files=5000, depth=2, fanout=10),
83
                     gen.GenSpec(name="r", seed="s", files=7, depth=1, fanout=4)):
84
            cache = gen.layout(spec)
85
            paths = [gen.file_path(spec, i, cache) for i in range(spec.files)]
86
            self.assertEqual(len(set(paths)), spec.files)
87
            self.assertTrue(all(p.count("/") + 1 == spec.depth for p in paths), spec)
88
89
    def test_content_unique_sized_and_deterministic(self) -> None:
90
        factory = gen.ContentFactory(SMALL)
91
        blobs = {factory.text(i, "r1") for i in range(SMALL.files)}
92
        self.assertEqual(len(blobs), SMALL.files)
93
        self.assertTrue(all(len(b) == SMALL.file_bytes for b in blobs))
94
        self.assertEqual(factory.text(3, "r9"), gen.ContentFactory(SMALL).text(3, "r9"))
95
        self.assertNotEqual(factory.text(3, "r9"), factory.text(3, "r8"))
96
97
    def test_binary_is_sparse_friendly_and_exact_size(self) -> None:
98
        factory = gen.ContentFactory(SMALL)
99
        data = b"".join(factory.binary_segments(0, 1))
100
        self.assertEqual(len(data), SMALL.binary_bytes)
101
        self.assertEqual(data[gen.SEGMENT_RANDOM:gen.SEGMENT], bytes(gen.SEGMENT - gen.SEGMENT_RANDOM))
102
        self.assertNotEqual(data[:gen.SEGMENT_RANDOM], bytes(gen.SEGMENT_RANDOM))
103
        sha1, _ = gen.binary_sha1_and_sha256(factory, 0, 1)
104
        self.assertEqual(sha1, gen.git_blob_id(data))
105
106
    def test_branch_changes_match_stream_facts(self) -> None:
107
        buffer = io.BytesIO()
108
        facts = gen.write_stream(SMALL, buffer)
109
        self.assertEqual(facts["branch_sample"]["changed_paths"],
110
                         sorted(path for _, path in gen.branch_changes(SMALL, 0)))
111
        self.assertEqual(facts["total_commits"], SMALL.commits + SMALL.branches + 2)
112
113
114
@unittest.skipUnless(GIT, "git not available")
115
class GeneratedFixtureTest(unittest.TestCase):
116
    @classmethod
117
    def setUpClass(cls) -> None:
118
        cls.tmp = Path(tempfile.mkdtemp(prefix="scale-gen-"))
119
        cls.manifest = gen.generate(SMALL, cls.tmp / "a", GIT)
120
        cls.repo = cls.tmp / "a" / "src.git"
121
122
    @classmethod
123
    def tearDownClass(cls) -> None:
124
        shutil.rmtree(cls.tmp, ignore_errors=True)
125
126
    def test_same_seed_same_fixture_hash(self) -> None:
127
        again = gen.generate(SMALL, self.tmp / "b", GIT)
128
        self.assertEqual(again["refs_sha256"], self.manifest["refs_sha256"])
129
        self.assertEqual(again["head_commit"], self.manifest["head_commit"])
130
131
    def test_reuse_is_detected(self) -> None:
132
        self.assertTrue(gen.generate(SMALL, self.tmp / "a", GIT)["reused"])
133
134
    def test_manifest_facts_agree_with_git(self) -> None:
135
        probe = self.manifest["probe_path"]
136
        log = git("log", "--oneline", "main", "--", probe, cwd=self.repo).splitlines()
137
        self.assertEqual(len(log), self.manifest["probe_path_commit_count"])
138
        self.assertEqual(int(git("rev-list", "--count", "main", cwd=self.repo)), SMALL.commits)
139
        changed = git("diff", "--name-only", "main...b00000", cwd=self.repo).split()
140
        self.assertEqual(sorted(changed), self.manifest["branch_sample"]["changed_paths"])
141
        self.assertEqual(len(git("tag", "--list", cwd=self.repo).split()), SMALL.tags)
142
        conflicts = subprocess.run([GIT, "merge-tree", "--write-tree", "--name-only", "bench-ours", "bench-theirs"],
143
                                   cwd=self.repo, capture_output=True, text=True)
144
        self.assertEqual(conflicts.returncode, 1)
145
        self.assertEqual(len(conflicts.stdout.split("\n\n")[0].splitlines()[1:]), SMALL.conflict_files)
146
147
    def test_binary_blob_id_matches_generator(self) -> None:
148
        blob = git("rev-parse", f"b00000:{gen.binary_path(0)}", cwd=self.repo).strip()
149
        # branch b00000 forks from the tip, where the binary is at revision binary_revisions
150
        sha1, _ = gen.binary_sha1_and_sha256(gen.ContentFactory(SMALL), 0, SMALL.binary_revisions)
151
        self.assertEqual(blob, sha1)
152
153
    def test_sizer_values(self) -> None:
154
        stats = sz.measure(self.repo, GIT)
155
        values = stats["values"]
156
        self.assertEqual(stats["checkout_scope"], "all_commits")
157
        self.assertEqual(values["uniqueCommitCount"], self.manifest["total_commits"])
158
        self.assertEqual(values["maxCheckoutPathDepth"], SMALL.depth)
159
        self.assertEqual(values["maxCheckoutBlobCount"], SMALL.files + SMALL.binaries + 1)  # + .gitignore
160
        self.assertEqual(values["referenceCount"], 1 + SMALL.branches + 2 + SMALL.tags)
161
        self.assertEqual(values["maxBlobSize"], SMALL.binary_bytes)
162
        self.assertEqual(values["maxHistoryDepth"], SMALL.commits + 1)
163
        self.assertEqual(values["maxCommitParentCount"], 1)
164
        self.assertEqual(values["uniqueTagCount"], 0)
165
        longest = max(len(p) for p in git("ls-tree", "-r", "--name-only", "main", cwd=self.repo).split())
166
        self.assertEqual(values["maxCheckoutPathLength"], longest)
167
        self.assertEqual(stats["level_of_concern"]["maxCheckoutPathDepth"],
168
                         round(SMALL.depth / 10, 4))
169
        compact = sz.compact(stats)
170
        self.assertEqual(compact["maxCheckoutPathDepth"], SMALL.depth)
171
172
    def test_sizer_ref_tips_scope_and_tag_depth(self) -> None:
173
        clone = self.tmp / "tags.git"
174
        subprocess.run([GIT, "clone", "-q", "--bare", str(self.repo), str(clone)], check=True, env=gen.git_env())
175
        env = {**gen.git_env(), "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
176
        subprocess.run([GIT, "-C", str(clone), "tag", "-a", "-m", "one", "a1", "main"], check=True, env=env)
177
        subprocess.run([GIT, "-C", str(clone), "tag", "-a", "-m", "two", "a2", "a1"], check=True, env=env)
178
        stats = sz.measure(clone, GIT, max_checkout_roots=2)
179
        self.assertEqual(stats["checkout_scope"], "ref_tips")
180
        self.assertEqual(stats["values"]["maxTagDepth"], 2)
181
        self.assertEqual(stats["values"]["uniqueTagCount"], 2)
182
        self.assertIsNone(stats["values"]["uniqueTreeEntries"])
183
184
185
@unittest.skipUnless(GIT, "git not available")
186
class RegistryPinTest(unittest.TestCase):
187
    """Cross-machine determinism: regenerating a tier reproduces the pinned identity."""
188
189
    def test_pinned_tiers_regenerate_identically(self) -> None:
190
        registry = json.loads((ROOT / "config" / "fixtures.json").read_text())["fixtures"]
191
        tmp = Path(tempfile.mkdtemp(prefix="scale-pin-"))
192
        try:
193
            checked = 0
194
            for name in ("fresh", "default"):
195
                entry = registry[f"scale-{name}-v1"]
196
                tier = tiers_mod.TIERS[name]
197
                self.assertEqual(entry["spec_sha256"], tier.spec.spec_sha256())
198
                self.assertEqual(entry["seed"], tier.spec.seed)
199
                manifest = gen.generate(tier.spec, tmp / name, GIT)
200
                self.assertEqual(manifest["refs_sha256"], entry["refs_sha256"], name)
201
                self.assertEqual(manifest["head_commit"], entry["head_commit"], name)
202
                checked += 1
203
            self.assertEqual(checked, 2)
204
        finally:
205
            shutil.rmtree(tmp, ignore_errors=True)
206
207
    def test_every_tier_is_registered_with_its_spec_hash(self) -> None:
208
        registry = json.loads((ROOT / "config" / "fixtures.json").read_text())["fixtures"]
209
        for name, tier in tiers_mod.TIERS.items():
210
            self.assertEqual(registry[f"scale-{name}-v1"]["spec_sha256"], tier.spec.spec_sha256(), name)
211
212
213
class SizerPureTest(unittest.TestCase):
214
    def test_parse_tree(self) -> None:
215
        oid = bytes(range(20))
216
        raw = b"100644 a.txt\0" + oid + b"40000 dir\0" + oid[::-1]
217
        self.assertEqual(sz.parse_tree(raw), [(b"100644", b"a.txt", oid), (b"40000", b"dir", oid[::-1])])
218
219
    def test_level_of_concern_uses_git_sizer_reference_values(self) -> None:
220
        self.assertEqual(sz.level_of_concern("maxCheckoutBlobCount", 1_000_000), 20.0)
221
        self.assertEqual(sz.level_of_concern("referenceCount", 25_000), 1.0)
222
        self.assertIsNone(sz.level_of_concern("maxBlobSize", None))
223
224
225
class TierTest(unittest.TestCase):
226
    def test_all_14_core_shapes_are_runnable_specs(self) -> None:
227
        shapes = tiers_mod.core_shape_tiers(0.05)
228
        self.assertEqual(len(shapes), 14)
229
        for shape_id, tier in shapes.items():
230
            tier.spec.validate()
231
            self.assertGreaterEqual(tier.spec.branches, 1, shape_id)
232
            self.assertEqual(tier.kind, "shape")
233
        self.assertEqual(shapes["deep-tree"].spec.depth, 64)
234
        self.assertEqual(shapes["conflict-heavy"].spec.conflict_files, 250)
235
        self.assertEqual(shapes["binary-history"].spec.binary_revisions, shapes["binary-history"].spec.commits)
236
        self.assertIn("build/", shapes["ignored-heavy"].spec.ignore_patterns)
237
238
    def test_scale_is_part_of_identity(self) -> None:
239
        a = tiers_mod.core_shape_tiers(0.05)["normal"]
240
        b = tiers_mod.core_shape_tiers(0.1)["normal"]
241
        self.assertNotEqual(a.fixture_id, b.fixture_id)
242
243
    def test_tier_ids_unique_and_xl_opt_in(self) -> None:
244
        ids = [tier.fixture_id for tier in tiers_mod.TIERS.values()]
245
        self.assertEqual(len(ids), len(set(ids)))
246
        with self.assertRaises(SystemExit):
247
            tiers_mod.resolve("xl-tree")
248
        self.assertEqual(tiers_mod.resolve("xl-tree", allow_xl=True).spec.files, 1_000_000)
249
        with self.assertRaises(SystemExit):
250
            tiers_mod.resolve("xl-combined", allow_xl=True)
251
        combined = tiers_mod.resolve("xl-combined", allow_xl=True, allow_xl_combined=True).spec
252
        self.assertGreaterEqual(combined.commits, 100_000)
253
        self.assertGreaterEqual(combined.files, 1_000_000)
254
        self.assertGreaterEqual(combined.depth, 32)
255
        self.assertGreaterEqual(combined.branches, 10_000)
256
        self.assertGreaterEqual(combined.binary_bytes, 2 << 30)
257
        for tier in tiers_mod.TIERS.values():
258
            tier.spec.validate()
259
260
261
class CatalogTest(unittest.TestCase):
262
    def test_scale_yaml_matches_catalog(self) -> None:
263
        self.assertEqual((ROOT / "scenarios" / "scale.yaml").read_text(), ops_mod.catalog_yaml())
264
265
    def test_tperf_refs_are_file_names_only(self) -> None:
266
        pattern = re.compile(r"^p\d{4}-[a-z0-9-]+\.sh$")
267
        for info in ops_mod.OP_INFO.values():
268
            for ref in info.tperf:
269
                self.assertRegex(ref, pattern)
270
271
    def test_units_cover_ops(self) -> None:
272
        self.assertEqual(sorted(ops_mod.ALL_OPS), sorted(ops_mod.OPS))
273
274
    def test_porcelain_paths(self) -> None:
275
        text = ' M a/b.txt\n D c.txt\nR  old.txt -> new.txt\n?? added/x/\nM "sp ace.txt"\n'
276
        self.assertEqual(ops_mod.porcelain_paths(text),
277
                         ["a/b.txt", "c.txt", "old.txt", "new.txt", "added/x/", "sp ace.txt"])
278
279
280
# ---------------------------------------------------------------------------
281
# Oracle tests with canned subject output
282
# ---------------------------------------------------------------------------
283
284
285
def cap(stdout: str = "", returncode: int = 0, raw: bytes | None = None) -> TimedCapture:
286
    data = stdout.encode()
287
    return TimedCapture(elapsed_ms=1.0, returncode=returncode, stdout_text=stdout[:4000], stdout_bytes=len(data),
288
                        stdout_truncated=False, stderr_text="", stderr_bytes=0, stderr_truncated=False,
289
                        stdout_full_text=stdout, stdout_raw=raw if raw is not None else data)
290
291
292
class OracleTest(unittest.TestCase):
293
    def setUp(self) -> None:
294
        self.tmp = Path(tempfile.mkdtemp(prefix="scale-oracle-"))
295
        self.spec = gen.GenSpec(name="o", seed="oracle", files=40, file_bytes=64, depth=3, fanout=4, commits=12,
296
                                branches=2, branch_commit_files=2)
297
        facts = gen.write_stream(self.spec, io.BytesIO())
298
        factory = gen.ContentFactory(self.spec)
299
        for kind in ("git", "oak"):
300
            work = self.tmp / kind / "work"
301
            for i in range(self.spec.files):
302
                path = work / gen.file_path(self.spec, i)
303
                path.parent.mkdir(parents=True, exist_ok=True)
304
                path.write_bytes(factory.text(i, "r1"))
305
        branch_paths = facts["branch_sample"]["changed_paths"]
306
        self.expected = ws.Expected(
307
            main_file_count=self.spec.files, sample={}, probe_path=facts["probe_path"], probe_bytes=b"probe\nx\n",
308
            probe_lines=2, probe_commit_count=facts["probe_path_commit_count"], main_commits=self.spec.commits,
309
            branch="b00000", branch_paths=branch_paths, branch_bytes={}, main_bytes_of_branch_paths={},
310
            conflict={}, protected=[facts["probe_path"], *branch_paths], all_paths_sample_size=0)
311
        tier = tiers_mod.Tier(tier_id="t", kind="tier", spec=self.spec, dirty=tiers_mod.DirtyShape(3, 2, 1, 1))
312
        root = self.tmp
313
        self.prepared = SimpleNamespace(tier=tier, expected=self.expected, state={}, env={}, git_bin=GIT or "git",
314
                                        oak_base_branch="base", oak_branches_created=2,
315
                                        path=lambda *parts: root.joinpath(*parts))
316
        self.untimed_calls: list = []
317
318
    def tearDown(self) -> None:
319
        shutil.rmtree(self.tmp, ignore_errors=True)
320
321
    def ctx(self, kind: str, *outputs, run: int = 1) -> ops_mod.Ctx:
322
        queue = list(outputs)
323
        subject = SimpleNamespace(name=kind, kind=kind, bin=kind)
324
325
        def fake_runner(command, cwd, env, admitted, **kwargs):
326
            item = queue.pop(0)
327
            return item() if callable(item) else item
328
329
        ctx = ops_mod.Ctx(self.prepared, subject, run, fake_runner, self.tmp / "scratch", 60.0)
330
        ctx.untimed = lambda args, cwd, check=True, binary=None: (  # type: ignore[assignment]
331
            self.untimed_calls.append(list(args)) or SimpleNamespace(returncode=0, stdout=b"", stderr=b""))
332
        ctx.git = lambda args, cwd, check=True: (  # type: ignore[assignment]
333
            self.untimed_calls.append(["git", *args]) or SimpleNamespace(returncode=0, stdout=b"", stderr=b""))
334
        return ctx
335
336
    def run_op(self, op: str, kind: str, *outputs: TimedCapture) -> ops_mod.Outcome:
337
        return ops_mod.execute(self.ctx(kind, *outputs), op)
338
339
    def test_status_clean(self) -> None:
340
        self.assertTrue(self.run_op("status.clean", "git", cap("")).oracle_ok)
341
        self.assertFalse(self.run_op("status.clean", "oak", cap("M a.txt\n")).oracle_ok)
342
343
    def test_log_counts(self) -> None:
344
        lines = "".join(f"h{i} c{i}\n" for i in range(self.spec.commits))
345
        self.assertTrue(self.run_op("log.full", "git", cap(lines)).oracle_ok)
346
        self.assertFalse(self.run_op("log.full", "oak", cap(lines + "extra\n")).oracle_ok)
347
        self.assertTrue(self.run_op("log.n", "oak", cap(lines)).oracle_ok)
348
        self.assertFalse(self.run_op("log.n", "oak", cap("h1 c1\n")).oracle_ok)
349
        probe = "".join(f"p{i}\n" for i in range(self.expected.probe_commit_count))
350
        self.assertTrue(self.run_op("log.path", "git", cap(probe)).oracle_ok)
351
        self.assertFalse(self.run_op("log.path", "oak", cap("No matching commits\n")).oracle_ok)
352
353
    def test_blame(self) -> None:
354
        self.assertTrue(self.run_op("blame", "git", cap("a 1) probe\nb 2) x\n")).oracle_ok)
355
        self.assertFalse(self.run_op("blame", "git", cap("a 1) probe\n")).oracle_ok)
356
        self.assertEqual(self.run_op("blame", "oak").skip_reason, "oak_cli_has_no_blame_or_annotate_command")
357
358
    def test_file_show(self) -> None:
359
        self.assertTrue(self.run_op("file.show", "git", cap(raw=b"probe\nx\n")).oracle_ok)
360
        self.assertFalse(self.run_op("file.show", "git", cap(raw=b"other\n")).oracle_ok)
361
        # oak writes --output; nothing written -> mismatch
362
        self.assertFalse(self.run_op("file.show", "oak", cap("{}")).oracle_ok)
363
364
    def test_branch_list(self) -> None:
365
        self.assertTrue(self.run_op("branch.list", "git", cap("* main\n  b00000\n  b00001\n")).oracle_ok)
366
        self.assertFalse(self.run_op("branch.list", "git", cap("* main\n")).oracle_ok)
367
        listing = json.dumps([{"name": "main"}, {"name": "b00000"}, {"name": "b00001"}])
368
        self.assertTrue(self.run_op("branch.list", "oak", cap(listing)).oracle_ok)
369
        self.assertFalse(self.run_op("branch.list", "oak", cap("not json")).oracle_ok)
370
371
    def test_review_branch_diff(self) -> None:
372
        paths = self.expected.branch_paths
373
        self.assertTrue(self.run_op("review.branch_diff", "git",
374
                                    cap("".join(f"M\t{p}\n" for p in paths))).oracle_ok)
375
        oak_ok = json.dumps({"changed_files": [{"path": p} for p in paths]})
376
        self.assertTrue(self.run_op("review.branch_diff", "oak", cap(oak_ok)).oracle_ok)
377
        self.assertFalse(self.run_op("review.branch_diff", "oak", cap(json.dumps({"changed_files": []}))).oracle_ok)
378
379
    def test_merge_preview_oak(self) -> None:
380
        good = json.dumps({"clean": True, "changed_files": [{"path": p} for p in self.expected.branch_paths],
381
                           "merge_safety": {"certified": False}})
382
        outcome = self.run_op("merge.preview", "oak", cap(good, returncode=7))
383
        self.assertTrue(outcome.oracle_ok)
384
        self.assertFalse(outcome.extra["merge_certified"])
385
        self.assertIn(["switch", "b00000"], self.untimed_calls)
386
        self.assertEqual(self.untimed_calls[-1], ["switch", "base"])  # always switched back
387
        bad = json.dumps({"clean": False, "changed_files": []})
388
        self.assertFalse(self.run_op("merge.preview", "oak", cap(bad)).oracle_ok)
389
        self.assertIsNotNone(self.run_op("merge.preview", "oak", cap(good, returncode=2)).failed)
390
391
    def test_local_merge_ops_skip_for_oak(self) -> None:
392
        self.assertIn("oak_serve_lacks_merge_to_main", self.run_op("merge.local", "oak").skip_reason)
393
        self.assertEqual(self.run_op("merge.conflict", "git").skip_reason, "fixture_has_no_conflict_shape")
394
395
    def _listing(self, kind: str = "git") -> str:
396
        state = self.prepared.state[kind]["dirty"]
397
        text = "".join(f" M {p}\n" for p in state["modified"]) + "".join(f" D {p}\n" for p in state["deleted"])
398
        return text + "".join(f" D {old}\n" for old, _ in state["renamed"]) + "?? added/\n?? renamed/\n"
399
400
    def test_dirty_cycle_status_and_diff_oracles(self) -> None:
401
        good = ops_mod.execute(self.ctx("git", lambda: cap(self._listing())), "status.dirty")
402
        self.assertTrue(good.oracle_ok, good.oracle_detail)
403
        dirty = self.prepared.state["git"]["dirty"]
404
        self.assertEqual(dirty["applied"], [3, 2, 1, 1])
405
        self.assertFalse(set(dirty["modified"]) & set(self.expected.protected))
406
        diff = "".join(f"diff --git a/{p} b/{p}\n" for p in dirty["modified"] + dirty["deleted"])
407
        self.assertTrue(ops_mod.execute(self.ctx("git", cap(diff)), "diff.dirty").oracle_ok)
408
        self.assertFalse(ops_mod.execute(self.ctx("git", cap("diff --git a/x b/x\n")), "diff.dirty").oracle_ok)
409
        partial = ops_mod.execute(self.ctx("oak", lambda: cap(self._listing().split("\n", 1)[1]), run=2),
410
                                  "status.dirty")
411
        self.assertFalse(partial.oracle_ok)
412
413
    def test_dirty_cycle_runs_on_throwaway_branch(self) -> None:
414
        ops_mod.execute(self.ctx("oak", lambda: cap(self._listing("oak"))), "status.dirty")
415
        self.assertIn(["switch", "-c", "dirty-oak-r1"], self.untimed_calls)
416
        self.untimed_calls.clear()
417
        out = ops_mod.execute(self.ctx("oak", cap("{}")), "commit.dirty")
418
        self.assertIsNone(out.failed, out.failed)
419
        self.assertEqual(self.untimed_calls[-1], ["switch", "base"])
420
        self.assertEqual(self.prepared.state["oak"]["harness_commits"], 0)
421
422
    def test_recover_returns_to_base(self) -> None:
423
        ctx = self.ctx("oak")
424
        self.assertIsNone(ops_mod.recover(ctx))
425
        self.assertEqual(self.untimed_calls[-1], ["switch", "base"])
426
427
    def test_commit_needs_dirty_state(self) -> None:
428
        self.assertIn("needs status.dirty", self.run_op("commit.dirty", "git").failed)
429
430
    def test_timed_failure_is_recorded_not_raised(self) -> None:
431
        outcome = self.run_op("status.clean", "oak", cap("", returncode=3))
432
        self.assertIn("exit 3", outcome.failed)
433
        self.assertIsNone(outcome.oracle_ok)
434
435
    def test_check_worktree(self) -> None:
436
        root = self.tmp / "git" / "work"
437
        path = gen.file_path(self.spec, 5)
438
        self.expected.sample[path] = gen.git_blob_id((root / path).read_bytes())
439
        ctx = self.ctx("git")
440
        ok, detail = ops_mod.check_worktree(ctx, root, self.spec.files)
441
        self.assertTrue(ok, detail)
442
        (root / path).write_bytes(b"changed")
443
        ok, detail = ops_mod.check_worktree(ctx, root, self.spec.files)
444
        self.assertFalse(ok)
445
        self.assertEqual(detail["sample_mismatch_count"], 1)
446
447
448
class RowTest(unittest.TestCase):
449
    def prepared(self) -> SimpleNamespace:
450
        tier = tiers_mod.TIERS["fresh"]
451
        fields = {"tier": "fresh", "tier_kind": "tier", "fixture_id": tier.fixture_id,
452
                  "fixture_manifest_sha256": "a" * 64, "fixture_spec_sha256": "b" * 64}
453
        return SimpleNamespace(tier=tier, fixture_fields=lambda: fields, phases={"ingest.oak_import": 1.5},
454
                               notes=[], manifest={"refs_sha256": "a" * 64, "spec_sha256": "b" * 64})
455
456
    def row(self, subject: str, kind: str, op: str, run: int, outcome: ops_mod.Outcome) -> dict:
457
        return runner_mod.build_row(bench_id="b", prepared=self.prepared(),
458
                                    subject=SimpleNamespace(name=subject, kind=kind), op=op, run=run,
459
                                    outcome=outcome, details={}, env_sample={}, lock_wait_ms=0.0,
460
                                    order_position=0, runner_fields={})
461
462
    def test_rows_validate_for_all_outcomes(self) -> None:
463
        ok = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["git", "status"], cap(""))], oracle_ok=True)
464
        skip = ops_mod.Outcome(skip_reason="no_equivalent")
465
        fail = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["oak", "status"], cap("", 2))], failed="exit 2")
466
        bad = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["oak", "log"], cap(""))], oracle_ok=False)
467
        rows = [self.row("git", "git", "status.clean", 1, ok), self.row("oak_installed", "oak", "blame", 1, skip),
468
                self.row("oak_installed", "oak", "status.clean", 1, fail),
469
                self.row("oak_installed", "oak", "log.path", 1, bad)]
470
        rows.append(runner_mod.abort_row(rows[0], "diff.dirty", "status.dirty"))
471
        rows += runner_mod.ingest_rows(self.prepared(), "b", {})
472
        self.assertEqual(validate_rows(rows, "scale"), [])
473
        self.assertEqual([r["returncode"] for r in rows[:5]], [0, 77, 1, 1, 77])
474
        self.assertEqual(rows[3]["failure"], "oracle_failed")
475
        self.assertFalse(any(r["claim_eligible"] for r in rows))
476
        self.assertTrue(all(r["oracle_timed"] is False for r in rows[:4]))
477
478
    def test_cell_report_gives_floors_and_mde(self) -> None:
479
        rows = []
480
        for run in range(1, 7):
481
            for subject, kind, base in (("git", "git", 10.0), ("git_aa", "git", 10.0),
482
                                        ("oak_installed", "oak", 20.0), ("oak_installed_aa", "oak", 20.0)):
483
                outcome = ops_mod.Outcome(timed=[ops_mod.Timed("main", [kind], cap(""))], oracle_ok=True)
484
                row = self.row(subject, kind, "status.clean", run, outcome)
485
                row["elapsed_ms"] = base + (run % 3) * 0.2
486
                rows.append(row)
487
        report = runner_mod.cell_report(rows, ["git", "git_aa", "oak_installed", "oak_installed_aa"])
488
        item = report[0]
489
        self.assertIsNotNone(item["git_aa_floor_pct"])
490
        self.assertIsNotNone(item["oak_vs_git_mde_pct_80"])
491
        self.assertIsNotNone(item["oak_vs_oak_mde_pct_80"])
492
        self.assertAlmostEqual(item["oak_vs_git_delta_pct_dev_only"], 100.0, delta=5)
493
        self.assertFalse(item["claim_eligible"])
494
        text = runner_mod.summary_markdown(self.prepared(), report,
495
                                           {"bench_id": "b", "runs": 6, "subjects": ["git"]})
496
        self.assertIn("DEVELOPMENT ONLY", text)
497
        self.assertRegex(text, r"\| 1\.9\dx \|")
498
499
500
@unittest.skipUnless(GIT, "git not available")
501
class GitOnlyScheduleTest(unittest.TestCase):
502
    """End to end on git only: prepare, interleave git + git_aa, every oracle passes."""
503
504
    def test_schedule(self) -> None:
505
        from oakbench.environment import base_env
506
        tmp = Path(tempfile.mkdtemp(prefix="scale-e2e-"))
507
        try:
508
            spec = gen.GenSpec(name="e2e", seed="e2e", files=24, file_bytes=64, depth=3, fanout=4, commits=9,
509
                               branches=2, conflict_files=2)
510
            tier = tiers_mod.Tier(tier_id="e2e", kind="tier", spec=spec, dirty=tiers_mod.DirtyShape(2, 1, 1, 1))
511
            env = base_env(home=tmp / "home")
512
            # default logger (stderr) on purpose: it must be importable and callable
513
            prepared = ws.prepare(tier, tmp / "work", git_bin=GIT, oak_bin="oak", env=env, kinds=("git",))
514
            subjects = [SimpleNamespace(name=n, kind="git", bin=Path(GIT)) for n in ("git", "git_aa")]
515
            try:
516
                rows = runner_mod.run_schedule(prepared, subjects, runs=1, ops=ops_mod.ALL_OPS, bench_id="e2e",
517
                                               seed=1, scratch=tmp / "scratch", details={}, runner_fields={},
518
                                               timeout_s=120, lock_wait_ms=None, log=lambda *_: None)
519
            finally:
520
                prepared.close()
521
            self.assertEqual(validate_rows(rows, "scale"), [])
522
            failed = [(r["operation"], r.get("failure"), r.get("oracle_detail")) for r in rows if r["returncode"] == 1]
523
            self.assertEqual(failed, [])
524
            measured = {r["operation"] for r in rows if r["returncode"] == 0}
525
            self.assertEqual(measured, set(ops_mod.ALL_OPS))
526
            self.assertTrue(all(r["oracle_ok"] for r in rows if r["returncode"] == 0))
527
        finally:
528
            shutil.rmtree(tmp, ignore_errors=True)
529
530
531
if __name__ == "__main__":
532
    unittest.main()