"""Unit tests for devx.ci.distribute_items.""" import pytest from click.testing import CliRunner from devx.ci.distribute_items import ( DEFAULT_WEIGHT, distribute, items_for_runner, main, parse_items, parse_weighted_items, ) class TestParseItems: def test_string_array(self) -> None: assert parse_items('["a", "b", "c"]') == ["a", "b", "c"] def test_object_array(self) -> None: raw = '[{"id": "a", "weight": 2}, {"id": "b"}]' assert parse_items(raw) == ["a", "b"] def test_empty_array(self) -> None: assert parse_items("[]") == [] def test_not_an_array(self) -> None: with pytest.raises(Exception, match="must be a JSON array"): parse_items('{"key": "value"}') def test_invalid_entry_type(self) -> None: with pytest.raises(Exception, match="must be a string or an object"): parse_items("[42]") def test_object_without_id(self) -> None: with pytest.raises(Exception, match="must be a string or an object"): parse_items('[{"weight": 2}]') class TestParseWeightedItems: def test_string_array_default_weights(self) -> None: items, weights = parse_weighted_items('["a", "b"]') assert items == ["a", "b"] assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT] def test_object_array_with_weights(self) -> None: items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]') assert items == ["a", "b"] assert weights == [5, 1] def test_object_array_missing_weight(self) -> None: items, weights = parse_weighted_items('[{"id": "a"}]') assert items == ["a"] assert weights == [DEFAULT_WEIGHT] def test_not_an_array(self) -> None: with pytest.raises(Exception, match="must be a JSON array"): parse_weighted_items('"hello"') def test_invalid_entry(self) -> None: with pytest.raises(Exception, match="must be a string or an object"): parse_weighted_items("[true]") class TestDistribute: def test_even_split(self) -> None: items = [f"vm-{i}" for i in range(6)] weights = [1] * 6 groups = distribute(items, weights, 3) assert len(groups) == 3 assert all(len(g) == 2 for g in groups) def test_uneven_split(self) -> None: items = [f"vm-{i}" for i in range(5)] weights = [1] * 5 groups = distribute(items, weights, 3) assert len(groups[0]) == 2 assert len(groups[1]) == 2 assert len(groups[2]) == 1 def test_more_runners_than_items(self) -> None: items = ["vm-a"] weights = [1] groups = distribute(items, weights, 5) assert len(groups) == 5 assert len(groups[0]) == 1 assert all(len(g) == 0 for g in groups[1:]) def test_lpt_heavy_item_on_least_loaded(self) -> None: items = ["heavy", "light1", "light2", "light3"] weights = [10, 1, 1, 1] groups = distribute(items, weights, 2) # Heavy item goes to runner 0, lights go to runner 1 (least loaded) assert "heavy" in groups[0] # Runner 1 should have more items but less total weight assert len(groups[1]) >= 2 def test_empty_items(self) -> None: groups = distribute([], [], 3) assert len(groups) == 3 assert all(len(g) == 0 for g in groups) def test_single_runner(self) -> None: items = ["a", "b", "c"] weights = [1, 2, 3] groups = distribute(items, weights, 1) assert len(groups) == 1 assert len(groups[0]) == 3 class TestItemsForRunner: def test_returns_assigned_subset(self) -> None: items = ["a", "b", "c", "d", "e", "f"] weights = [1] * 6 result = items_for_runner(items, weights, 0, 3) assert len(result) == 2 assert all(item in items for item in result) def test_out_of_range(self) -> None: with pytest.raises(Exception, match="out of range"): items_for_runner(["a"], [1], 5, 3) def test_negative_index(self) -> None: with pytest.raises(Exception, match="out of range"): items_for_runner(["a"], [1], -1, 3) class TestMain: def test_stdin_string_array(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]') assert result.exit_code == 0 # LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0 # All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b" assert "a" in result.output def test_stdin_object_array(self) -> None: runner = CliRunner() result = runner.invoke( main, ["--runner-index", "1", "--max-runners", "2"], input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]', ) assert result.exit_code == 0 assert "a" in result.output def test_items_file(self, tmp_path: object) -> None: import pathlib items_file = pathlib.Path(str(tmp_path)) / "items.json" items_file.write_text('["x", "y", "z"]') runner = CliRunner() result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"]) assert result.exit_code == 0 assert "x" in result.output def test_print_all_groups_no_runner_index(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]') assert result.exit_code == 0 assert "Runner 0:" in result.output assert "Runner 1:" in result.output def test_empty_stdin(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="") assert result.exit_code == 0 # Empty input → empty assigned items assert result.output.strip() == "" def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None: import pathlib gh_env = pathlib.Path(str(tmp_path)) / "gh_env" gh_env.write_text("") monkeypatch.setenv("GITHUB_ENV", str(gh_env)) runner = CliRunner() result = runner.invoke( main, ["--runner-index", "1", "--max-runners", "2", "--github-env"], input='["a", "b"]', ) assert result.exit_code == 0 content = gh_env.read_text() assert "ASSIGNED_ITEMS=" in content assert "SKIP=false" in content def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None: import pathlib gh_env = pathlib.Path(str(tmp_path)) / "gh_env" gh_env.write_text("") monkeypatch.setenv("GITHUB_ENV", str(gh_env)) runner = CliRunner() result = runner.invoke( main, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"], input='["a"]', ) assert result.exit_code == 0 content = gh_env.read_text() assert "ASSIGNED_ITEMS=" in content assert "SKIP=true" in content def test_runner_index_zero(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--runner-index", "0"], input='["a"]') assert result.exit_code != 0 assert "out of range" in result.output def test_default_max_runners(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--runner-index", "1"], input='["a"]') assert result.exit_code == 0 assert "a" in result.output def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_ENV", raising=False) runner = CliRunner() result = runner.invoke( main, ["--runner-index", "1", "--github-env"], input='["a"]', ) assert result.exit_code != 0 assert "GITHUB_ENV" in result.output def test_invalid_json(self) -> None: runner = CliRunner() result = runner.invoke(main, ["--runner-index", "1"], input="not json") assert result.exit_code != 0 def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None: import pathlib gh_env = pathlib.Path(str(tmp_path)) / "gh_env" gh_env.write_text("") monkeypatch.setenv("GITHUB_ENV", str(gh_env)) runner = CliRunner() # Items with newlines in their IDs would trigger multiline syntax # Normal items don't have newlines, but test the path anyway result = runner.invoke( main, ["--runner-index", "1", "--max-runners", "1", "--github-env"], input='["a\\nb"]', ) assert result.exit_code == 0 content = gh_env.read_text() # Item "a\nb" contains a newline → heredoc syntax assert "ASSIGNED_ITEMS<<" in content