Viewing files at commit b76ab8b42dfdCommit detailsView latest

Browse files
tests/test_scale_suite.py 516 lines · 27.7 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
        return ctx
333
334
    def run_op(self, op: str, kind: str, *outputs: TimedCapture) -> ops_mod.Outcome:
335
        return ops_mod.execute(self.ctx(kind, *outputs), op)
336
337
    def test_status_clean(self) -> None:
338
        self.assertTrue(self.run_op("status.clean", "git", cap("")).oracle_ok)
339
        self.assertFalse(self.run_op("status.clean", "oak", cap("M a.txt\n")).oracle_ok)
340
341
    def test_log_counts(self) -> None:
342
        lines = "".join(f"h{i} c{i}\n" for i in range(self.spec.commits))
343
        self.assertTrue(self.run_op("log.full", "git", cap(lines)).oracle_ok)
344
        self.assertFalse(self.run_op("log.full", "oak", cap(lines + "extra\n")).oracle_ok)
345
        self.assertTrue(self.run_op("log.n", "oak", cap(lines)).oracle_ok)
346
        self.assertFalse(self.run_op("log.n", "oak", cap("h1 c1\n")).oracle_ok)
347
        probe = "".join(f"p{i}\n" for i in range(self.expected.probe_commit_count))
348
        self.assertTrue(self.run_op("log.path", "git", cap(probe)).oracle_ok)
349
        self.assertFalse(self.run_op("log.path", "oak", cap("No matching commits\n")).oracle_ok)
350
351
    def test_blame(self) -> None:
352
        self.assertTrue(self.run_op("blame", "git", cap("a 1) probe\nb 2) x\n")).oracle_ok)
353
        self.assertFalse(self.run_op("blame", "git", cap("a 1) probe\n")).oracle_ok)
354
        self.assertEqual(self.run_op("blame", "oak").skip_reason, "oak_cli_has_no_blame_or_annotate_command")
355
356
    def test_file_show(self) -> None:
357
        self.assertTrue(self.run_op("file.show", "git", cap(raw=b"probe\nx\n")).oracle_ok)
358
        self.assertFalse(self.run_op("file.show", "git", cap(raw=b"other\n")).oracle_ok)
359
        # oak writes --output; nothing written -> mismatch
360
        self.assertFalse(self.run_op("file.show", "oak", cap("{}")).oracle_ok)
361
362
    def test_branch_list(self) -> None:
363
        self.assertTrue(self.run_op("branch.list", "git", cap("* main\n  b00000\n  b00001\n")).oracle_ok)
364
        self.assertFalse(self.run_op("branch.list", "git", cap("* main\n")).oracle_ok)
365
        listing = json.dumps([{"name": "main"}, {"name": "b00000"}, {"name": "b00001"}])
366
        self.assertTrue(self.run_op("branch.list", "oak", cap(listing)).oracle_ok)
367
        self.assertFalse(self.run_op("branch.list", "oak", cap("not json")).oracle_ok)
368
369
    def test_review_branch_diff(self) -> None:
370
        paths = self.expected.branch_paths
371
        self.assertTrue(self.run_op("review.branch_diff", "git",
372
                                    cap("".join(f"M\t{p}\n" for p in paths))).oracle_ok)
373
        oak_ok = json.dumps({"changed_files": [{"path": p} for p in paths]})
374
        self.assertTrue(self.run_op("review.branch_diff", "oak", cap(oak_ok)).oracle_ok)
375
        self.assertFalse(self.run_op("review.branch_diff", "oak", cap(json.dumps({"changed_files": []}))).oracle_ok)
376
377
    def test_merge_preview_oak(self) -> None:
378
        good = json.dumps({"clean": True, "changed_files": [{"path": p} for p in self.expected.branch_paths],
379
                           "merge_safety": {"certified": False}})
380
        outcome = self.run_op("merge.preview", "oak", cap(good, returncode=7))
381
        self.assertTrue(outcome.oracle_ok)
382
        self.assertFalse(outcome.extra["merge_certified"])
383
        self.assertIn(["switch", "b00000"], self.untimed_calls)
384
        self.assertEqual(self.untimed_calls[-1], ["switch", "base"])  # always switched back
385
        bad = json.dumps({"clean": False, "changed_files": []})
386
        self.assertFalse(self.run_op("merge.preview", "oak", cap(bad)).oracle_ok)
387
        self.assertIsNotNone(self.run_op("merge.preview", "oak", cap(good, returncode=2)).failed)
388
389
    def test_local_merge_ops_skip_for_oak(self) -> None:
390
        self.assertIn("oak_serve_lacks_merge_to_main", self.run_op("merge.local", "oak").skip_reason)
391
        self.assertEqual(self.run_op("merge.conflict", "git").skip_reason, "fixture_has_no_conflict_shape")
392
393
    def _listing(self) -> str:
394
        state = self.prepared.state["git"]["dirty"]
395
        text = "".join(f" M {p}\n" for p in state["modified"]) + "".join(f" D {p}\n" for p in state["deleted"])
396
        return text + "".join(f" D {old}\n" for old, _ in state["renamed"]) + "?? added/\n?? renamed/\n"
397
398
    def test_dirty_cycle_status_and_diff_oracles(self) -> None:
399
        good = ops_mod.execute(self.ctx("git", lambda: cap(self._listing())), "status.dirty")
400
        self.assertTrue(good.oracle_ok, good.oracle_detail)
401
        dirty = self.prepared.state["git"]["dirty"]
402
        self.assertEqual(dirty["applied"], [3, 2, 1, 1])
403
        self.assertFalse(set(dirty["modified"]) & set(self.expected.protected))
404
        diff = "".join(f"diff --git a/{p} b/{p}\n" for p in dirty["modified"] + dirty["deleted"])
405
        self.assertTrue(ops_mod.execute(self.ctx("git", cap(diff)), "diff.dirty").oracle_ok)
406
        self.assertFalse(ops_mod.execute(self.ctx("git", cap("diff --git a/x b/x\n")), "diff.dirty").oracle_ok)
407
        partial = ops_mod.execute(self.ctx("oak", lambda: cap(self._listing().split("\n", 1)[1]), run=2),
408
                                  "status.dirty")
409
        self.assertFalse(partial.oracle_ok)
410
411
    def test_commit_needs_dirty_state(self) -> None:
412
        self.assertIn("needs status.dirty", self.run_op("commit.dirty", "git").failed)
413
414
    def test_timed_failure_is_recorded_not_raised(self) -> None:
415
        outcome = self.run_op("status.clean", "oak", cap("", returncode=3))
416
        self.assertIn("exit 3", outcome.failed)
417
        self.assertIsNone(outcome.oracle_ok)
418
419
    def test_check_worktree(self) -> None:
420
        root = self.tmp / "git" / "work"
421
        path = gen.file_path(self.spec, 5)
422
        self.expected.sample[path] = gen.git_blob_id((root / path).read_bytes())
423
        ctx = self.ctx("git")
424
        ok, detail = ops_mod.check_worktree(ctx, root, self.spec.files)
425
        self.assertTrue(ok, detail)
426
        (root / path).write_bytes(b"changed")
427
        ok, detail = ops_mod.check_worktree(ctx, root, self.spec.files)
428
        self.assertFalse(ok)
429
        self.assertEqual(detail["sample_mismatch_count"], 1)
430
431
432
class RowTest(unittest.TestCase):
433
    def prepared(self) -> SimpleNamespace:
434
        tier = tiers_mod.TIERS["fresh"]
435
        fields = {"tier": "fresh", "tier_kind": "tier", "fixture_id": tier.fixture_id,
436
                  "fixture_manifest_sha256": "a" * 64, "fixture_spec_sha256": "b" * 64}
437
        return SimpleNamespace(tier=tier, fixture_fields=lambda: fields, phases={"ingest.oak_import": 1.5},
438
                               notes=[], manifest={"refs_sha256": "a" * 64, "spec_sha256": "b" * 64})
439
440
    def row(self, subject: str, kind: str, op: str, run: int, outcome: ops_mod.Outcome) -> dict:
441
        return runner_mod.build_row(bench_id="b", prepared=self.prepared(),
442
                                    subject=SimpleNamespace(name=subject, kind=kind), op=op, run=run,
443
                                    outcome=outcome, details={}, env_sample={}, lock_wait_ms=0.0,
444
                                    order_position=0, runner_fields={})
445
446
    def test_rows_validate_for_all_outcomes(self) -> None:
447
        ok = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["git", "status"], cap(""))], oracle_ok=True)
448
        skip = ops_mod.Outcome(skip_reason="no_equivalent")
449
        fail = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["oak", "status"], cap("", 2))], failed="exit 2")
450
        bad = ops_mod.Outcome(timed=[ops_mod.Timed("main", ["oak", "log"], cap(""))], oracle_ok=False)
451
        rows = [self.row("git", "git", "status.clean", 1, ok), self.row("oak_installed", "oak", "blame", 1, skip),
452
                self.row("oak_installed", "oak", "status.clean", 1, fail),
453
                self.row("oak_installed", "oak", "log.path", 1, bad)]
454
        rows.append(runner_mod.abort_row(rows[0], "diff.dirty", "status.dirty"))
455
        rows += runner_mod.ingest_rows(self.prepared(), "b", {})
456
        self.assertEqual(validate_rows(rows, "scale"), [])
457
        self.assertEqual([r["returncode"] for r in rows[:5]], [0, 77, 1, 1, 77])
458
        self.assertEqual(rows[3]["failure"], "oracle_failed")
459
        self.assertFalse(any(r["claim_eligible"] for r in rows))
460
        self.assertTrue(all(r["oracle_timed"] is False for r in rows[:4]))
461
462
    def test_cell_report_gives_floors_and_mde(self) -> None:
463
        rows = []
464
        for run in range(1, 7):
465
            for subject, kind, base in (("git", "git", 10.0), ("git_aa", "git", 10.0),
466
                                        ("oak_installed", "oak", 20.0), ("oak_installed_aa", "oak", 20.0)):
467
                outcome = ops_mod.Outcome(timed=[ops_mod.Timed("main", [kind], cap(""))], oracle_ok=True)
468
                row = self.row(subject, kind, "status.clean", run, outcome)
469
                row["elapsed_ms"] = base + (run % 3) * 0.2
470
                rows.append(row)
471
        report = runner_mod.cell_report(rows, ["git", "git_aa", "oak_installed", "oak_installed_aa"])
472
        item = report[0]
473
        self.assertIsNotNone(item["git_aa_floor_pct"])
474
        self.assertIsNotNone(item["oak_vs_git_mde_pct_80"])
475
        self.assertIsNotNone(item["oak_vs_oak_mde_pct_80"])
476
        self.assertAlmostEqual(item["oak_vs_git_delta_pct_dev_only"], 100.0, delta=5)
477
        self.assertFalse(item["claim_eligible"])
478
        text = runner_mod.summary_markdown(self.prepared(), report,
479
                                           {"bench_id": "b", "runs": 6, "subjects": ["git"]})
480
        self.assertIn("DEVELOPMENT ONLY", text)
481
        self.assertRegex(text, r"\| 1\.9\dx \|")
482
483
484
@unittest.skipUnless(GIT, "git not available")
485
class GitOnlyScheduleTest(unittest.TestCase):
486
    """End to end on git only: prepare, interleave git + git_aa, every oracle passes."""
487
488
    def test_schedule(self) -> None:
489
        from oakbench.environment import base_env
490
        tmp = Path(tempfile.mkdtemp(prefix="scale-e2e-"))
491
        try:
492
            spec = gen.GenSpec(name="e2e", seed="e2e", files=24, file_bytes=64, depth=3, fanout=4, commits=9,
493
                               branches=2, conflict_files=2)
494
            tier = tiers_mod.Tier(tier_id="e2e", kind="tier", spec=spec, dirty=tiers_mod.DirtyShape(2, 1, 1, 1))
495
            env = base_env(home=tmp / "home")
496
            # default logger (stderr) on purpose: it must be importable and callable
497
            prepared = ws.prepare(tier, tmp / "work", git_bin=GIT, oak_bin="oak", env=env, kinds=("git",))
498
            subjects = [SimpleNamespace(name=n, kind="git", bin=Path(GIT)) for n in ("git", "git_aa")]
499
            try:
500
                rows = runner_mod.run_schedule(prepared, subjects, runs=1, ops=ops_mod.ALL_OPS, bench_id="e2e",
501
                                               seed=1, scratch=tmp / "scratch", details={}, runner_fields={},
502
                                               timeout_s=120, lock_wait_ms=None, log=lambda *_: None)
503
            finally:
504
                prepared.close()
505
            self.assertEqual(validate_rows(rows, "scale"), [])
506
            failed = [(r["operation"], r.get("failure"), r.get("oracle_detail")) for r in rows if r["returncode"] == 1]
507
            self.assertEqual(failed, [])
508
            measured = {r["operation"] for r in rows if r["returncode"] == 0}
509
            self.assertEqual(measured, set(ops_mod.ALL_OPS))
510
            self.assertTrue(all(r["oracle_ok"] for r in rows if r["returncode"] == 0))
511
        finally:
512
            shutil.rmtree(tmp, ignore_errors=True)
513
514
515
if __name__ == "__main__":
516
    unittest.main()