Public Access
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
251 lines
8.9 KiB
Python
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
|