Sprint 3 : protocoles de vote et boite a outils
Backend: - Sessions de vote : list, close, tally, threshold details, auto-expiration - Protocoles : update, simulate, meta-gouvernance, formulas CRUD - Service vote enrichi : close_session, get_threshold_details, nuanced breakdown - Schemas : ThresholdDetailOut, VoteResultOut, FormulaSimulationRequest/Result - WebSocket broadcast sur chaque vote + fermeture session - 25 nouveaux tests (threshold details, close, nuanced, simulation) Frontend: - 5 composants vote : VoteBinary, VoteNuanced, ThresholdGauge, FormulaDisplay, VoteHistory - 3 composants protocoles : ProtocolPicker, FormulaEditor, ModeParamsDisplay - Simulateur de formules interactif (page /protocols/formulas) - Page detail protocole (/protocols/[id]) - Composable useWebSocket (live updates) - Composable useVoteFormula (calcul client-side reactif) - Integration KaTeX pour rendu LaTeX des formules Documentation: - API reference : 8 nouveaux endpoints documentes - Formules : tables d'inertie, parametres detailles, simulation API - Guide vote : vote binaire/nuance, jauge, historique, simulateur, meta-gouvernance 55 tests passes (+ 1 skipped), 126 fichiers total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
478
backend/app/tests/test_votes.py
Normal file
478
backend/app/tests/test_votes.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""Tests for vote service engine functions: threshold details, session logic, nuanced evaluation, and simulation.
|
||||
|
||||
All tests are pure unit tests that exercise the engine functions directly
|
||||
without any database dependency.
|
||||
|
||||
Real-world reference case:
|
||||
Vote Engagement Forgeron v2.0.0 (Feb 2026)
|
||||
wot_size=7224, votes_for=97, votes_against=23, total=120
|
||||
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threshold details computation: real Forgeron case (97/23/7224)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThresholdDetailsForgeron:
|
||||
"""Simulate the threshold details computation that the service would return
|
||||
for the real Engagement Forgeron v2.0.0 vote."""
|
||||
|
||||
WOT_SIZE = 7224
|
||||
VOTES_FOR = 97
|
||||
VOTES_AGAINST = 23
|
||||
TOTAL = 120
|
||||
SMITH_SIZE = 20
|
||||
SMITH_EXPONENT = 0.1
|
||||
TECHCOMM_SIZE = 5
|
||||
TECHCOMM_EXPONENT = 0.1
|
||||
|
||||
def _compute_details(self) -> dict:
|
||||
"""Reproduce the threshold details logic from vote_service.get_threshold_details."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=self.WOT_SIZE,
|
||||
total_votes=self.TOTAL,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
constant_base=0.0,
|
||||
)
|
||||
wot_passed = self.VOTES_FOR >= wot_thresh
|
||||
|
||||
smith_thresh = smith_threshold(self.SMITH_SIZE, self.SMITH_EXPONENT)
|
||||
# Assume all smith members voted for
|
||||
smith_votes_for = 5
|
||||
smith_passed = smith_votes_for >= smith_thresh
|
||||
|
||||
techcomm_thresh = techcomm_threshold(self.TECHCOMM_SIZE, self.TECHCOMM_EXPONENT)
|
||||
# Assume 2 techcomm members voted for
|
||||
techcomm_votes_for = 2
|
||||
techcomm_passed = techcomm_votes_for >= techcomm_thresh
|
||||
|
||||
overall_passed = wot_passed and smith_passed and techcomm_passed
|
||||
participation_rate = self.TOTAL / self.WOT_SIZE
|
||||
|
||||
return {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"votes_for": self.VOTES_FOR,
|
||||
"votes_against": self.VOTES_AGAINST,
|
||||
"votes_total": self.TOTAL,
|
||||
"wot_passed": wot_passed,
|
||||
"smith_passed": smith_passed,
|
||||
"techcomm_passed": techcomm_passed,
|
||||
"overall_passed": overall_passed,
|
||||
"participation_rate": round(participation_rate, 6),
|
||||
"formula_params": {
|
||||
"M": 50, "B": 0.1, "G": 0.2, "C": 0.0,
|
||||
"S": self.SMITH_EXPONENT, "T": self.TECHCOMM_EXPONENT,
|
||||
},
|
||||
}
|
||||
|
||||
def test_wot_threshold_value(self):
|
||||
"""WoT threshold for Forgeron vote should be in the expected range."""
|
||||
details = self._compute_details()
|
||||
assert 80 <= details["wot_threshold"] <= 120
|
||||
# 97 votes_for must pass
|
||||
assert details["wot_passed"] is True
|
||||
|
||||
def test_smith_threshold_value(self):
|
||||
"""Smith threshold: ceil(20^0.1) = ceil(1.35) = 2."""
|
||||
details = self._compute_details()
|
||||
assert details["smith_threshold"] == 2
|
||||
assert details["smith_passed"] is True
|
||||
|
||||
def test_techcomm_threshold_value(self):
|
||||
"""TechComm threshold: ceil(5^0.1) = ceil(1.175) = 2."""
|
||||
details = self._compute_details()
|
||||
assert details["techcomm_threshold"] == 2
|
||||
assert details["techcomm_passed"] is True
|
||||
|
||||
def test_overall_pass(self):
|
||||
"""All three criteria pass => overall adopted."""
|
||||
details = self._compute_details()
|
||||
assert details["overall_passed"] is True
|
||||
|
||||
def test_participation_rate(self):
|
||||
"""Participation rate for 120/7224 ~ 1.66%."""
|
||||
details = self._compute_details()
|
||||
expected = round(120 / 7224, 6)
|
||||
assert details["participation_rate"] == expected
|
||||
assert details["participation_rate"] < 0.02 # less than 2%
|
||||
|
||||
def test_formula_params_present(self):
|
||||
"""All formula params must be present and correct."""
|
||||
details = self._compute_details()
|
||||
params = details["formula_params"]
|
||||
assert params["M"] == 50
|
||||
assert params["B"] == 0.1
|
||||
assert params["G"] == 0.2
|
||||
assert params["C"] == 0.0
|
||||
assert params["S"] == 0.1
|
||||
assert params["T"] == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close session behavior (engine-level logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCloseSessionLogic:
|
||||
"""Test the logic that would execute when a session is closed:
|
||||
computing the final tally and determining adopted/rejected."""
|
||||
|
||||
def test_binary_vote_adopted(self):
|
||||
"""97 for / 23 against out of 7224 WoT => adopted."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
adopted = 97 >= threshold
|
||||
assert adopted is True
|
||||
result = "adopted" if adopted else "rejected"
|
||||
assert result == "adopted"
|
||||
|
||||
def test_binary_vote_rejected(self):
|
||||
"""50 for / 70 against out of 7224 WoT => rejected."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
adopted = 50 >= threshold
|
||||
assert adopted is False
|
||||
result = "adopted" if adopted else "rejected"
|
||||
assert result == "rejected"
|
||||
|
||||
def test_close_with_zero_votes(self):
|
||||
"""Session with 0 votes => threshold is ~0 (B^W), effectively rejected
|
||||
because 0 votes_for cannot meet even a tiny threshold."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=0,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# B^W = 0.1^7224 -> effectively 0, ceil(0) = 0
|
||||
# But with 0 votes_for, 0 >= 0 is True
|
||||
# This is a degenerate case; in practice sessions with 0 votes
|
||||
# would be marked invalid
|
||||
assert threshold == 0 or threshold == math.ceil(0.1 ** 7224)
|
||||
|
||||
def test_close_high_participation(self):
|
||||
"""3000/7224 participating, 2500 for / 500 against => should pass.
|
||||
At ~41.5% participation with G=0.2, the inertia factor is low,
|
||||
so a strong supermajority should pass.
|
||||
"""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=3000,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# At ~41.5% participation, threshold is lower than near-unanimity
|
||||
# but still above simple majority (1500)
|
||||
assert threshold > 1500, f"Threshold {threshold} should be above simple majority"
|
||||
assert threshold < 3000, f"Threshold {threshold} should be below total votes"
|
||||
adopted = 2500 >= threshold
|
||||
assert adopted is True
|
||||
|
||||
def test_close_barely_fails(self):
|
||||
"""Use exact threshold to verify borderline rejection."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# votes_for = threshold - 1 => should fail
|
||||
barely_fail = (threshold - 1) >= threshold
|
||||
assert barely_fail is False
|
||||
|
||||
def test_close_smith_criterion_blocks(self):
|
||||
"""Even if WoT passes, failing Smith criterion blocks adoption."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
wot_pass = 97 >= threshold
|
||||
|
||||
smith_required = smith_threshold(20, 0.1) # ceil(20^0.1) = 2
|
||||
smith_ok = 1 >= smith_required # Only 1 smith voted -> fails
|
||||
|
||||
adopted = wot_pass and smith_ok
|
||||
assert wot_pass is True
|
||||
assert smith_ok is False
|
||||
assert adopted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nuanced vote result evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNuancedVoteResult:
|
||||
"""Test nuanced vote evaluation with per-level breakdown,
|
||||
as would be returned by compute_result for nuanced votes."""
|
||||
|
||||
def test_nuanced_adopted_with_breakdown(self):
|
||||
"""Standard nuanced vote that passes threshold and min_participants."""
|
||||
votes = [5] * 30 + [4] * 20 + [3] * 15 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 75, positive = 30+20+15 = 65
|
||||
assert result["total"] == 75
|
||||
assert result["positive_count"] == 65
|
||||
assert result["positive_pct"] == pytest.approx(86.67, abs=0.1)
|
||||
assert result["adopted"] is True
|
||||
|
||||
# Verify per-level breakdown
|
||||
assert result["per_level_counts"][5] == 30
|
||||
assert result["per_level_counts"][4] == 20
|
||||
assert result["per_level_counts"][3] == 15
|
||||
assert result["per_level_counts"][2] == 5
|
||||
assert result["per_level_counts"][1] == 3
|
||||
assert result["per_level_counts"][0] == 2
|
||||
|
||||
def test_nuanced_rejected_threshold_not_met(self):
|
||||
"""Nuanced vote where positive percentage is below threshold."""
|
||||
votes = [5] * 10 + [4] * 10 + [3] * 10 + [2] * 15 + [1] * 15 + [0] * 10
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 70, positive = 10+10+10 = 30
|
||||
assert result["total"] == 70
|
||||
assert result["positive_count"] == 30
|
||||
assert result["positive_pct"] == pytest.approx(42.86, abs=0.1)
|
||||
assert result["threshold_met"] is False
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_nuanced_all_neutre(self):
|
||||
"""All voters at level 3 (NEUTRE) still counts as positive."""
|
||||
votes = [3] * 60
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 60
|
||||
assert result["positive_count"] == 60
|
||||
assert result["positive_pct"] == 100.0
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_nuanced_mixed_heavy_negative(self):
|
||||
"""Majority at levels 0-2 => rejected."""
|
||||
votes = [0] * 20 + [1] * 20 + [2] * 15 + [3] * 2 + [4] * 1 + [5] * 1
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Total = 59, positive = 2+1+1 = 4
|
||||
assert result["total"] == 59
|
||||
assert result["positive_count"] == 4
|
||||
assert result["positive_pct"] < 10
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_nuanced_breakdown_dict_structure(self):
|
||||
"""Verify the breakdown structure matches what the service builds."""
|
||||
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
evaluation = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
# Simulate the nuanced_breakdown dict the service builds
|
||||
nuanced_breakdown = {
|
||||
"per_level_counts": evaluation["per_level_counts"],
|
||||
"positive_count": evaluation["positive_count"],
|
||||
"positive_pct": evaluation["positive_pct"],
|
||||
"threshold_met": evaluation["threshold_met"],
|
||||
"min_participants_met": evaluation["min_participants_met"],
|
||||
"threshold_pct": 80,
|
||||
"min_participants": 59,
|
||||
}
|
||||
|
||||
assert "per_level_counts" in nuanced_breakdown
|
||||
assert nuanced_breakdown["threshold_pct"] == 80
|
||||
assert nuanced_breakdown["min_participants"] == 59
|
||||
assert nuanced_breakdown["positive_count"] == 59
|
||||
assert nuanced_breakdown["threshold_met"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simulation endpoint logic (pure engine functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSimulationLogic:
|
||||
"""Test the formula simulation that the /simulate endpoint performs,
|
||||
exercising all three threshold engine functions together."""
|
||||
|
||||
def test_simulation_forgeron_case(self):
|
||||
"""Simulate the Forgeron vote: wot=7224, total=120, M50 B.1 G.2."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
|
||||
# Derived values (matching the simulate endpoint logic)
|
||||
w = 7224
|
||||
t = 120
|
||||
m = 0.5
|
||||
g = 0.2
|
||||
participation_rate = t / w
|
||||
turnout_ratio = min(t / w, 1.0)
|
||||
inertia_factor = 1.0 - turnout_ratio ** g
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
assert 80 <= wot_thresh <= 120
|
||||
assert participation_rate == pytest.approx(0.016611, abs=0.001)
|
||||
assert 0 < inertia_factor < 1
|
||||
assert required_ratio > m # Inertia pushes above simple majority
|
||||
|
||||
def test_simulation_full_participation(self):
|
||||
"""At 100% participation, threshold approaches simple majority."""
|
||||
wot_size = 100
|
||||
total = 100
|
||||
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
|
||||
# At full participation, turnout_ratio = 1.0
|
||||
# inertia_factor = 1.0 - 1.0^0.2 = 0
|
||||
# required_ratio = 0.5 + 0.5*0 = 0.5
|
||||
# threshold = 0 + 0.1^100 + 0.5 * 100 = ~50
|
||||
assert wot_thresh == math.ceil(0.1 ** 100 + 0.5 * 100)
|
||||
assert wot_thresh == 50
|
||||
|
||||
def test_simulation_with_smith_and_techcomm(self):
|
||||
"""Simulate with all three criteria."""
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
|
||||
assert wot_thresh > 0
|
||||
assert smith_thresh == 2 # ceil(20^0.1) = ceil(1.35) = 2
|
||||
assert techcomm_thresh == 2 # ceil(5^0.1) = ceil(1.175) = 2
|
||||
|
||||
def test_simulation_varying_majority(self):
|
||||
"""Higher majority_pct increases the threshold at full participation."""
|
||||
thresh_50 = wot_threshold(wot_size=100, total_votes=100, majority_pct=50)
|
||||
thresh_66 = wot_threshold(wot_size=100, total_votes=100, majority_pct=66)
|
||||
thresh_80 = wot_threshold(wot_size=100, total_votes=100, majority_pct=80)
|
||||
|
||||
assert thresh_50 < thresh_66 < thresh_80
|
||||
|
||||
def test_simulation_varying_gradient(self):
|
||||
"""Higher gradient exponent means more inertia at partial participation.
|
||||
|
||||
With T/W < 1, a higher G makes (T/W)^G smaller, so
|
||||
inertia_factor = 1 - (T/W)^G becomes larger, raising the threshold.
|
||||
At 50% participation: (0.5)^0.2 ~ 0.87, (0.5)^1.0 = 0.5.
|
||||
"""
|
||||
thresh_g02 = wot_threshold(
|
||||
wot_size=200, total_votes=100,
|
||||
majority_pct=50, gradient_exponent=0.2,
|
||||
)
|
||||
thresh_g10 = wot_threshold(
|
||||
wot_size=200, total_votes=100,
|
||||
majority_pct=50, gradient_exponent=1.0,
|
||||
)
|
||||
|
||||
# Higher G = more inertia at partial participation = higher threshold
|
||||
assert thresh_g10 > thresh_g02
|
||||
|
||||
def test_simulation_zero_votes(self):
|
||||
"""Zero votes produces a minimal threshold."""
|
||||
thresh = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=0,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# B^W = 0.1^7224 ~ 0, ceil(0) = 0
|
||||
assert thresh == 0
|
||||
|
||||
def test_simulation_small_wot(self):
|
||||
"""Small WoT size (e.g. 5 members)."""
|
||||
thresh = wot_threshold(
|
||||
wot_size=5,
|
||||
total_votes=3,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With 3/5 participation, should require more than simple majority of 3
|
||||
assert thresh >= 2
|
||||
assert thresh <= 3
|
||||
|
||||
def test_simulation_result_structure(self):
|
||||
"""Verify the simulation produces all expected values for the API response."""
|
||||
w = 7224
|
||||
t = 120
|
||||
m_pct = 50
|
||||
m = m_pct / 100.0
|
||||
b = 0.1
|
||||
g = 0.2
|
||||
c = 0.0
|
||||
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=w, total_votes=t,
|
||||
majority_pct=m_pct, base_exponent=b,
|
||||
gradient_exponent=g, constant_base=c,
|
||||
)
|
||||
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
|
||||
participation_rate = t / w
|
||||
turnout_ratio = min(t / w, 1.0)
|
||||
inertia_factor = 1.0 - turnout_ratio ** g
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
# Build the simulation result dict (matching FormulaSimulationResult schema)
|
||||
result = {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"participation_rate": round(participation_rate, 6),
|
||||
"required_ratio": round(required_ratio, 6),
|
||||
"inertia_factor": round(inertia_factor, 6),
|
||||
}
|
||||
|
||||
assert isinstance(result["wot_threshold"], int)
|
||||
assert isinstance(result["smith_threshold"], int)
|
||||
assert isinstance(result["techcomm_threshold"], int)
|
||||
assert 0 < result["participation_rate"] < 1
|
||||
assert 0 < result["required_ratio"] <= 1
|
||||
assert 0 < result["inertia_factor"] < 1
|
||||
Reference in New Issue
Block a user