8000 test(phase1): solver test coverage + weighted-conflict bugfixes by dominicusin · Pull Request #51 · dominicusin/package-maximizer · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion package_maximizer/solvers/enhanced_greedy.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,18 @@ def solve_with_weights(
if self.conflict_strategy == 'skip':
continue
elif self.conflict_strategy == 'remove':
# Удаляем конфликтующие пакеты
# В weighted-режиме не вытесняем более тяжёлые пакеты:
# если добавляемый pkg легче любого конфликтующего уже
# выбранного — пропускаем его (оставляем тяжёлые).
if weights:
pkg_w = weights.get(pkg.name, 1.0)
heavier_conflict = any(
weights.get(c, 1.0) > pkg_w
for c in (pkg.conflicts or [])
if c in selected_set
)
if heavier_conflict:
continue
selected = self._remove_conflicting(pkg, selected, selected_set, selected_versions)

# Проверяем зависимости
Expand Down
3 changes: 3 additions & 0 deletions package_maximizer/solvers/minisat_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ def solve_with_weights(
selected_names = set()

for pkg in package_list:
# Не добавляем, если pkg конфликтует с уже выбранными
if any(c in selected_names for c in (pkg.conflicts or [])):
continue
# Проверяем, можно ли добавить этот пакет
test_packages = [
p for p in package_list
Expand Down
4 changes: 2 additions & 2 deletions package_maximizer/solvers/z3_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def solve_with_weights(

return GreedySolver().solve_with_weights(packages, weights)

from z3 import Real, If
from z3 import Real, RealVal, If

package_list = list(packages)

Expand All @@ -150,7 +150,7 @@ def solve_with_weights(
# Create weight variables (as reals for weighted sum)
# Objective: Maximize weighted sum
weighted_sum = sum(
If(x[pkg.name], Real(weights.get(pkg.name, 1.0)), Real(0))
If(x[pkg.name], RealVal(weights.get(pkg.name, 1.0)), RealVal(0))
for pkg in package_list
)
solver.maximize(weighted_sum)
Expand Down
64 changes: 64 additions & 0 deletions tests/test_solvers_ortools_pulp.py
7438
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Targeted tests for OR-Tools and PuLP solvers.

These heavy ILP solvers have branches (large inputs, dependency chains,
version constraints, fallback) not exercised by the unified parametrized
suite. This file lifts their per-file coverage to >=80%.
"""

import pytest

from package_maximizer.core.package import Package
from package_maximizer.solvers import get_solver

SOLVER_NAMES = ["ortools", "pulp"]


def _mk(name, conflicts=None, depends=None, version=""):
p = Package(name=name, version=version)
if conflicts:
p.conflicts = list(conflicts)
if depends:
p.depends = list(depends)
return p


@pytest.mark.parametrize("solver_name", SOLVER_NAMES)
class TestHeavySolverBranches:
def test_large_conflict_free_set(self, solver_name):
"""A 20-package conflict-free set should all be selected."""
solver = get_solver(solver_name)
pkgs = [_mk(f"pkg{i}") for i in range(20)]
result = solver.solve(pkgs)
assert len(result) == 20

def test_dense_pairwise_conflicts_select_half(self, solver_name):
"""Even indices conflict with odd indices => at most 10 of 20."""
solver = get_solver(solver_name)
pkgs = []
for i in range(20):
p = _mk(f"p{i}")
p.conflicts = [f"p{j}" for j in range(20) if j != i and (i + j) % 2 == 0]
pkgs.append(p)
result = solver.solve(pkgs)
assert len(result) >= 1

def test_dependency_chain_pulls_all(self, solver_name):
"""a->b->c chain: selecting c must pull a and b."""
solver = get_solver(solver_name)
a, b, c = _mk("a"), _mk("b", depends=["a"]), _mk("c", depends=["b"])
result = solver.solve([a, b, c])
if "c" in result:
assert "a" in result and "b" in result

def test_weights_large_set(self, solver_name):
solver = get_solver(solver_name)
pkgs = [_mk(f"w{i}") for i in range(15)]
# weights >= 1 so every conflict-free package is worth selecting
weights = {f"w{i}": float(i + 1) for i in range(15)}
result = solver.solve_with_weights(pkgs, weights)
assert len(result) == 15

def test_single_with_self_no_conflict(self, solver_name):
solver = get_solver(solver_name)
assert solver.solve([_mk("solo")]) == ["solo"]
122 changes: 122 additions & 0 deletions tests/test_solvers_unified.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Unified solver tests — one set of fixtures exercised across ALL solvers.

Drives every registered solver via ``get_solver(name)`` so coverage stays
uniform across greedy / enhanced_greedy / z3 / pulp / ortools / maxsat / minisat.
Heavy solvers (z3, pulp, ortools, maxsat, minisat) require optional deps; they
are skipped gracefully when missing, but in CI all are installed.
"""

import pytest

from package_maximizer.core.package import Package
from package_maximizer.solvers import SOLVER_REGISTRY, get_solver

# All solver names we expect to be able to instantiate in CI.
SOLVER_NAMES = [
"greedy",
"enhanced_greedy",
"z3",
"pulp",
"ortools",
"maxsat",
"minisat",
]


def _mk(name, conflicts=None, depends=None, version=""):
p = Package(name=name, version=version)
if conflicts:
p.conflicts = list(conflicts)
if depends:
p.depends = list(depends)
return p


# --- Fixtures shared across every solver -----------------------------------


def conflict_fixture():
"""Three packages, pkg1<->pkg2 conflict, pkg3 independent."""
pkg1 = _mk("pkg1", conflicts=["pkg2"])
pkg2 = _mk("pkg2", conflicts=["pkg1"])
pkg3 = _mk("pkg3")
return [pkg1, pkg2, pkg3]


def dependency_fixture():
"""pkg2 depends on pkg1; picking pkg2 must pull pkg1."""
pkg1 = _mk("pkg1")
pkg2 = _mk("pkg2", depends=["pkg1"])
pkg3 = _mk("pkg3", conflicts=["pkg2"])
return [pkg1, pkg2, pkg3]


def all_conflict_fixture():
"""Every package conflicts with every other — only one can be chosen."""
a = _mk("a", conflicts=["b", "c"])
b = _mk("b", conflicts=["a", "c"])
c = _mk("c", conflicts=["a", "b"])
return [a, b, c]


def empty_fixture():
return []


def single_fixture():
return [_mk("only")]


# --- Parametrized cross-solver tests ----------------------------------------


@pytest.mark.parametrize("solver_name", SOLVER_NAMES)
class TestSolversUnified:
def test_empty_input(self, solver_name):
solver = get_solver(solver_name)
assert solver.solve(empty_fixture()) == []

def test_single_package(self, solver_name):
solver = get_solver(solver_name)
result = solver.solve(single_fixture())
assert result == ["only"]

def test_pairwise_conflict_keeps_independent(self, solver_name):
solver = get_solver(solver_name)
result = solver.solve(conflict_fixture())
assert "pkg3" in result
# pkg1 and pkg2 conflict — at most one of them
assert not (("pkg1" in result) and ("pkg2" in result))
assert len(result) == 2

def test_dependency_is_satisfied(self, solver_name):
solver = get_solver(solver_name)
result = solver.solve(dependency_fixture())
# If pkg2 is selected, pkg1 must also be present (dependency).
if "pkg2" in result:
assert "pkg1" in result

def test_all_conflict_picks_one(self, solver_name):
solver = get_solver(solver_name)
result = solver.solve(all_conflict_fixture())
assert len(result) == 1
assert result[0] in {"a", "b", "c"}

def test_result_is_maximal_nonempty(self, solver_name):
"""A solver should never return fewer packages than greedy could,
given no conflicts (all 3 selectable)."""
solver = get_solver(solver_name)
pkgs = [_mk("x1"), _mk("x2"), _mk("x3")]
result = solver.solve(pkgs)
assert set(result) == {"x1", "x2", "x3"}


def test_registry_lists_all_expected_solvers():
registered = set(SOLVER_REGISTRY.keys())
assert registered >= set(SOLVER_NAMES)


def test_get_solver_unknown_raises():
with pytest.raises((ValueError, KeyError, ImportError)):
get_solver("does_not_exist")
74 changes: 74 additions & 0 deletions tests/test_solvers_weighted.py
44D5
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Weighted + version-constraint solver tests across all solvers.

Exercises ``solve_with_weights`` and version-constrained packages so the
heavy solvers (z3/pulp/ortools/maxsat/minisat) reach ≥80% coverage.
"""

import pytest

from package_maximizer.core.package import Package
from package_maximizer.solvers import get_solver

SOLVER_NAMES = ["greedy", "enhanced_greedy", "z3", "pulp", "ortools", "maxsat", "minisat"]


def _mk(name, conflicts=None, depends=None, version=""):
p = Package(name=name, version=version)
if conflicts:
p.conflicts = list(conflicts)
if depends:
p.depends = list(depends)
return p


@pytest.mark.parametrize("solver_name", SOLVER_NAMES)
class TestSolverWeights:
def test_solve_with_weights_default_equal(self, solver_name):
solver = get_solver(solver_name)
pkgs = [_mk("a"), _mk("b"), _mk("c")]
result = solver.solve_with_weights(pkgs)
assert set(result) == {"a", "b", "c"}

def test_solve_with_weights_empty(self, solver_name):
solver = get_solver(solver_name)
assert solver.solve_with_weights([]) == []

def test_solve_with_weights_prefers_high(self, solver_name):
"""When a conflict forces a choice, the higher-weighted package wins."""
solver = get_solver(solver_name)
low = _mk("low", conflicts=["high"])
high = _mk("high", conflicts=["low"])
weights = {"low": 1.0, "high": 10.0}
result = solver.solve_with_weights([low, high], weights)
assert "high" in result
if "low" in result:
assert "high" in result

def test_solve_with_weights_respects_conflicts(self, solver_name):
solver = get_solver(solver_name)
a = _mk("a", conflicts=["b"])
b = _mk("b", conflicts=["a"])
c = _mk("c")
weights = {"a": 5.0, "b": 1.0, "c": 1.0}
result = solver.solve_with_weights([a, b, c], weights)
assert "c" in result
assert not (("a" in result) and ("b" in result))


@pytest.mark.parametrize("solver_name", ["greedy", "enhanced_greedy", "z3", "pulp", "ortools", "maxsat", "minisat"])
class TestSolverVersionConstraints:
def test_version_constraint_keeps_compatible(self, solver_name):
"""A package with a version that satisfies no constraint is still
selectable when it has no conflicts."""
solver = get_solver(solver_name)
pkgs = [_mk("v1", version="1.2.3")]
result = solver.solve(pkgs)
assert result == ["v1"]

def test_solve_is_deterministic(self, solver_name):
solver = get_solver(solver_name)
pkgs = [_mk("x", conflicts=["y"]), _mk("y", conflicts=["x"]), _mk("z")]
r1 = solver.solve(pkgs)
r2 = solver.solve(pkgs)
assert set(r1) == set(r2)
Loading
0