"""CLI-level tests for grid parsing / normalization (issue #11).""" import json import pytest from nix_estimator import cli def _p(name): return f"/nix/store/{'0' * 32}-{name}.drv" @pytest.fixture def stub_graph(monkeypatch, tmp_path): """Stub out the nix-invoking graph layer with a deterministic toy DAG.""" leaves = [_p(f"heavylib{i}") for i in range(4)] root, sink = _p("root"), _p("sink") closure = {d: {} for d in [root, *leaves, sink]} preds = {root: [], sink: leaves, **{leaf: [root] for leaf in leaves}} nodes = set(closure) monkeypatch.setattr(cli.graph, "derivation_closure", lambda *a, **k: closure) monkeypatch.setattr(cli.graph, "to_build_set", lambda *a, **k: nodes) monkeypatch.setattr(cli.graph, "build_dag", lambda *a, **k: (preds, nodes)) hist = tmp_path / "history.json" hist.write_text( json.dumps( {"root": 1.0, "sink": 1.0, **{f"heavylib{i}": 20.0 for i in range(4)}} ) ) return str(hist) def _run_json(capsys, argv): assert cli.main(argv) == 0 return json.loads(capsys.readouterr().out) def test_unsorted_cores_match_sorted(stub_graph, capsys): sorted_out = _run_json( capsys, [".#x", "--history", stub_graph, "--cores", "8,16,32", "--json"] ) unsorted_out = _run_json( capsys, [".#x", "--history", stub_graph, "--cores", "32,8,16", "--json"] ) assert unsorted_out["recommendation"] == sorted_out["recommendation"] assert unsorted_out["grid"] == sorted_out["grid"] def test_unsorted_nodes_match_sorted(stub_graph, capsys): sorted_out = _run_json( capsys, [".#x", "--history", stub_graph, "--nodes", "1,2,4,8", "--json"] ) unsorted_out = _run_json( capsys, [".#x", "--history", stub_graph, "--nodes", "8,2,1,4", "--json"] ) assert unsorted_out["recommendation"] == sorted_out["recommendation"] def test_non_positive_cores_rejected(stub_graph, capsys): with pytest.raises(SystemExit) as exc: cli.main([".#x", "--history", stub_graph, "--cores", "8,0,16", "--json"]) assert exc.value.code != 0 assert "positive" in capsys.readouterr().err def test_negative_nodes_rejected(stub_graph, capsys): with pytest.raises(SystemExit) as exc: cli.main([".#x", "--history", stub_graph, "--nodes", "1,-2,4", "--json"]) assert exc.value.code != 0 def test_non_integer_cores_rejected(stub_graph, capsys): with pytest.raises(SystemExit) as exc: cli.main([".#x", "--history", stub_graph, "--cores", "8,abc", "--json"]) assert exc.value.code != 0 assert "integer" in capsys.readouterr().err # --- new feature surfaces (issues #12–#16) --------------------------------- def test_backcompat_plain_estimate(stub_graph, capsys): """Plain `nix-estimate --json` still parses with no subcommand.""" out = _run_json(capsys, [".#foo", "--history", stub_graph, "--json"]) assert out["attr"] == ".#foo" assert out["max_jobs"] == 1 assert out["node_ram_gb"] is None def test_max_jobs_accepted_and_affects_output(stub_graph, capsys): base = _run_json(capsys, [".#x", "--history", stub_graph, "--json"]) wide = _run_json( capsys, [".#x", "--history", stub_graph, "--max-jobs", "4", "--json"] ) assert base["max_jobs"] == 1 and wide["max_jobs"] == 4 # Sharing cores across 4 jobs changes at least one grid makespan. assert wide["grid"] != base["grid"] def test_node_ram_gb_accepted(stub_graph, capsys): out = _run_json( capsys, [".#x", "--history", stub_graph, "--max-jobs", "4", "--node-ram-gb", "8", "--json"], ) assert out["node_ram_gb"] == 8.0 def test_timeline_renders_report(stub_graph, capsys): assert cli.main([".#x", "--history", stub_graph, "--timeline"]) == 0 out = capsys.readouterr().out assert "timeline — recommended shape" in out assert "node0" in out def test_timeline_json_carries_assignments(stub_graph, capsys): out = _run_json( capsys, [".#x", "--history", stub_graph, "--timeline", "--json"] ) assert "timeline" in out tl = out["timeline"] assert tl["makespan_min"] > 0 # every scheduled task name appears in some lane tasks = [t[0] for lane in tl["assignments"].values() for t in lane] assert "sink" in tasks def test_provision_emits_yaml_to_stdout(stub_graph, capsys): assert cli.main([".#x", "--history", stub_graph, "--provision"]) == 0 cap = capsys.readouterr() assert "kind: EphemeralBuilder" in cap.out assert "apiVersion: builder-arbitrage.oleks.space/v1" in cap.out assert "class: nix-builder" in cap.out assert "replicas:" in cap.out and "cores:" in cap.out # human report is diverted to stderr, not stdout assert "RECOMMENDATION" in cap.err assert "RECOMMENDATION" not in cap.out def test_provision_custom_class_and_name(stub_graph, capsys): assert cli.main( [".#x", "--history", stub_graph, "--provision", "runner", "--provision-name", "my-builder"] ) == 0 out = capsys.readouterr().out assert "class: runner" in out assert "name: my-builder" in out def test_mine_subcommand_writes_json(monkeypatch, capsys, tmp_path): """`mine` runs mine_history (stubbed) and writes {name: minutes} JSON.""" calls = [] def fake_mine_history(installables, **kw): calls.append(list(installables)) return {"hello": 2.0, "world": 5.0} monkeypatch.setattr(cli.miner, "mine_history", fake_mine_history) out_file = tmp_path / "hist.json" rc = cli.main(["mine", ".#hello", ".#world", "--repeat", "3", "-o", str(out_file)]) assert rc == 0 assert len(calls) == 3 # repeated data = json.loads(out_file.read_text()) assert data == {"hello": 2.0, "world": 5.0} def test_mine_subcommand_to_stdout(monkeypatch, capsys): monkeypatch.setattr( cli.miner, "mine_history", lambda inst, **kw: {"pkg": 1.5} ) assert cli.main(["mine", ".#pkg"]) == 0 assert json.loads(capsys.readouterr().out) == {"pkg": 1.5}