Files
devx/tests/unit/test_distribute_items.py
T
emil f7f53941a1
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 41s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m53s
Build Images / cleanup (push) Successful in 7m33s
DEVX-92: feat: add distribute_items CI tool for parallel VM deployment
2026-06-28 01:58:42 +00:00

251 lines
8.9 KiB
Python

"""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