9553d3263d
Emit N documents (one per node; no replica field) using the real
arbitrage.oleks.space/v1alpha1 schema: top-level class + requirements
{arch,cores,ramGb}, namespace builder-arbitrage, class label. Rename
public API to render_ephemeral_builders; wire --node-ram-gb into ramGb.
192 lines
6.2 KiB
Python
192 lines
6.2 KiB
Python
"""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 <attr> --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: arbitrage.oleks.space/v1alpha1" in cap.out
|
||
assert "class: nix-builder" in cap.out
|
||
assert "requirements:" in cap.out and "cores:" in cap.out
|
||
assert "namespace: builder-arbitrage" 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-1" 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}
|