Sprint 1 : scaffolding complet de Glibredecision
Plateforme de decisions collectives pour Duniter/G1. Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services, moteur de vote avec formule d'inertie WoT/Smith/TechComm). Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores). Infrastructure Docker + Woodpecker CI + Traefik. Documentation technique et utilisateur (15 fichiers). Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote. 30 tests unitaires (formules, mode params, vote nuance) -- tous verts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/tests/__init__.py
Normal file
0
backend/app/tests/__init__.py
Normal file
75
backend/app/tests/test_mode_params.py
Normal file
75
backend/app/tests/test_mode_params.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Tests for mode-params string parser."""
|
||||
|
||||
from app.engine.mode_params import parse_mode_params
|
||||
|
||||
|
||||
class TestParseModeParams:
|
||||
"""Parse compact parameter strings into structured dicts."""
|
||||
|
||||
def test_standard_params(self):
|
||||
"""D30M50B.1G.2 => standard Licence G1 params."""
|
||||
result = parse_mode_params("D30M50B.1G.2")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
# Optional criteria absent
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] is None
|
||||
|
||||
def test_with_smith_exponent(self):
|
||||
"""D30M50B.1G.2S.1 => standard + smith_exponent=0.1."""
|
||||
result = parse_mode_params("D30M50B.1G.2S.1")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["smith_exponent"] == 0.1
|
||||
assert result["techcomm_exponent"] is None
|
||||
|
||||
def test_with_techcomm_exponent(self):
|
||||
"""D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1."""
|
||||
result = parse_mode_params("D30M50B.1G.2T.1")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] == 0.1
|
||||
|
||||
def test_full_params_with_constant(self):
|
||||
"""D30M50B1G.5C10 => integer base, gradient=0.5, constant=10."""
|
||||
result = parse_mode_params("D30M50B1G.5C10")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 1.0
|
||||
assert result["gradient_exponent"] == 0.5
|
||||
assert result["constant_base"] == 10.0
|
||||
|
||||
def test_empty_string_defaults(self):
|
||||
"""Empty string returns all defaults."""
|
||||
result = parse_mode_params("")
|
||||
assert result["duration_days"] == 30
|
||||
assert result["majority_pct"] == 50
|
||||
assert result["base_exponent"] == 0.1
|
||||
assert result["gradient_exponent"] == 0.2
|
||||
assert result["constant_base"] == 0.0
|
||||
assert result["smith_exponent"] is None
|
||||
assert result["techcomm_exponent"] is None
|
||||
assert result["ratio_multiplier"] is None
|
||||
assert result["is_ratio_mode"] is False
|
||||
|
||||
def test_whitespace_only_returns_defaults(self):
|
||||
"""Whitespace-only string treated as empty."""
|
||||
result = parse_mode_params(" ")
|
||||
assert result["duration_days"] == 30
|
||||
|
||||
def test_roundtrip_consistency(self):
|
||||
"""Parsing a standard string then re-checking all keys."""
|
||||
result = parse_mode_params("D30M50B.1G.2")
|
||||
expected_keys = {
|
||||
"duration_days", "majority_pct", "base_exponent",
|
||||
"gradient_exponent", "constant_base", "smith_exponent",
|
||||
"techcomm_exponent", "ratio_multiplier", "is_ratio_mode",
|
||||
}
|
||||
assert set(result.keys()) == expected_keys
|
||||
120
backend/app/tests/test_nuanced.py
Normal file
120
backend/app/tests/test_nuanced.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for six-level nuanced vote evaluation.
|
||||
|
||||
Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
|
||||
Positive = levels 3 + 4 + 5
|
||||
Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
|
||||
|
||||
class TestNuancedVoteAdoption:
|
||||
"""Cases where the vote should be adopted."""
|
||||
|
||||
def test_59_positive_10_negative_adopted(self):
|
||||
"""59 positive (levels 3-5) + 10 negative = 69 total.
|
||||
positive_pct = 59/69 ~ 85.5% >= 80% and 69 >= 59 => adopted.
|
||||
"""
|
||||
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 69
|
||||
assert result["positive_count"] == 59
|
||||
assert result["positive_pct"] == pytest.approx(85.51, abs=0.1)
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_all_tout_a_fait_adopted(self):
|
||||
"""All 59 voters at level 5 => 100% positive, adopted."""
|
||||
votes = [5] * 59
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 59
|
||||
assert result["positive_count"] == 59
|
||||
assert result["positive_pct"] == 100.0
|
||||
assert result["adopted"] is True
|
||||
|
||||
|
||||
class TestNuancedVoteRejection:
|
||||
"""Cases where the vote should be rejected."""
|
||||
|
||||
def test_40_positive_30_negative_rejected(self):
|
||||
"""40 positive + 30 negative = 70 total.
|
||||
positive_pct = 40/70 ~ 57.14% < 80% => threshold not met.
|
||||
"""
|
||||
votes = [5] * 15 + [4] * 15 + [3] * 10 + [2] * 10 + [1] * 10 + [0] * 10
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 70
|
||||
assert result["positive_count"] == 40
|
||||
assert result["positive_pct"] == pytest.approx(57.14, abs=0.1)
|
||||
assert result["threshold_met"] is False
|
||||
assert result["min_participants_met"] is True # 70 >= 59
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_min_participants_not_met(self):
|
||||
"""50 positive + 5 negative = 55 total < 59 min_participants.
|
||||
Even though 50/55 ~ 90.9% > 80%, adoption fails on min_participants.
|
||||
"""
|
||||
votes = [5] * 30 + [4] * 10 + [3] * 10 + [1] * 3 + [0] * 2
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 55
|
||||
assert result["positive_count"] == 50
|
||||
assert result["positive_pct"] > 80
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is False
|
||||
assert result["adopted"] is False
|
||||
|
||||
|
||||
class TestNuancedVoteEdgeCases:
|
||||
"""Edge cases and exact boundary conditions."""
|
||||
|
||||
def test_exact_threshold_80_percent(self):
|
||||
"""Exactly 80% positive votes should pass the threshold."""
|
||||
# 80 positive out of 100 = exactly 80%
|
||||
votes = [5] * 40 + [4] * 20 + [3] * 20 + [2] * 10 + [1] * 5 + [0] * 5
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 100
|
||||
assert result["positive_count"] == 80
|
||||
assert result["positive_pct"] == 80.0
|
||||
assert result["threshold_met"] is True
|
||||
assert result["min_participants_met"] is True
|
||||
assert result["adopted"] is True
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""79 positive out of 100 = 79% < 80% => rejected."""
|
||||
votes = [5] * 39 + [4] * 20 + [3] * 20 + [2] * 11 + [1] * 5 + [0] * 5
|
||||
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
|
||||
|
||||
assert result["total"] == 100
|
||||
assert result["positive_count"] == 79
|
||||
assert result["positive_pct"] == 79.0
|
||||
assert result["threshold_met"] is False
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_empty_votes(self):
|
||||
"""Zero votes => not adopted."""
|
||||
result = evaluate_nuanced([], threshold_pct=80, min_participants=59)
|
||||
assert result["total"] == 0
|
||||
assert result["positive_count"] == 0
|
||||
assert result["positive_pct"] == 0.0
|
||||
assert result["adopted"] is False
|
||||
|
||||
def test_invalid_vote_level(self):
|
||||
"""Vote level outside 0-5 raises ValueError."""
|
||||
with pytest.raises(ValueError, match="invalide"):
|
||||
evaluate_nuanced([5, 3, 6])
|
||||
|
||||
def test_per_level_counts(self):
|
||||
"""Verify per-level breakdown is correct."""
|
||||
votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
|
||||
result = evaluate_nuanced(votes, threshold_pct=50, min_participants=1)
|
||||
|
||||
assert result["per_level_counts"] == {0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2}
|
||||
assert result["positive_count"] == 6 # 2+2+2
|
||||
assert result["total"] == 9
|
||||
180
backend/app/tests/test_threshold.py
Normal file
180
backend/app/tests/test_threshold.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Tests for WoT threshold formula, Smith threshold, and TechComm threshold.
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
import math
|
||||
import pytest
|
||||
|
||||
from app.engine.threshold import wot_threshold
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: real-world vote Forgeron
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdForgeron:
|
||||
"""Test with the actual Engagement Forgeron v2.0.0 vote numbers."""
|
||||
|
||||
def test_forgeron_vote_passes(self):
|
||||
"""97 votes_for out of 120 total (wot=7224) must pass."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With low participation (120/7224 ~ 1.66%), near-unanimity is required.
|
||||
# The historical threshold was 94, and 97 >= 94.
|
||||
assert 97 >= threshold
|
||||
# The threshold should be high relative to total votes (inertia effect)
|
||||
assert threshold > 60, f"Threshold {threshold} should be well above simple majority"
|
||||
|
||||
def test_forgeron_vote_threshold_value(self):
|
||||
"""Verify the computed threshold is in a reasonable range."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=120,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# At ~1.66% participation, inertia should push threshold close to 78-95%
|
||||
# of total votes. The exact value depends on the formula.
|
||||
assert 80 <= threshold <= 120
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: low participation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdLowParticipation:
|
||||
"""With very low participation, near-unanimity should be required."""
|
||||
|
||||
def test_ten_votes_out_of_7224(self):
|
||||
"""10 voters out of 7224 => nearly all must vote 'for'."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=10,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With participation ratio 10/7224 ~ 0.14%, threshold should be
|
||||
# very close to total_votes (near-unanimity).
|
||||
assert threshold >= 9, f"Expected near-unanimity but got threshold={threshold}"
|
||||
assert threshold <= 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: high participation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdHighParticipation:
|
||||
"""With high participation, threshold should approach simple majority M."""
|
||||
|
||||
def test_3000_votes_out_of_7224(self):
|
||||
"""3000/7224 ~ 41.5% participation => threshold closer to 50%."""
|
||||
threshold = wot_threshold(
|
||||
wot_size=7224,
|
||||
total_votes=3000,
|
||||
majority_pct=50,
|
||||
base_exponent=0.1,
|
||||
gradient_exponent=0.2,
|
||||
)
|
||||
# With ~42% participation, the inertia factor diminishes.
|
||||
# threshold should be well below 90% of votes but above simple majority.
|
||||
simple_majority = math.ceil(3000 * 0.5)
|
||||
assert threshold >= simple_majority, (
|
||||
f"Threshold {threshold} should be at least simple majority {simple_majority}"
|
||||
)
|
||||
# Should be noticeably less than near-unanimity
|
||||
assert threshold < 2700, (
|
||||
f"Threshold {threshold} should be much less than near-unanimity at high participation"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WoT threshold: edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWotThresholdEdgeCases:
|
||||
"""Edge-case behaviour."""
|
||||
|
||||
def test_zero_total_votes(self):
|
||||
"""With zero votes, threshold is ceil(C + B^W)."""
|
||||
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 is effectively 0
|
||||
expected = math.ceil(0.0 + 0.1 ** 7224)
|
||||
assert threshold == expected
|
||||
|
||||
def test_invalid_wot_size_zero(self):
|
||||
with pytest.raises(ValueError, match="wot_size"):
|
||||
wot_threshold(wot_size=0, total_votes=10)
|
||||
|
||||
def test_invalid_negative_votes(self):
|
||||
with pytest.raises(ValueError, match="total_votes"):
|
||||
wot_threshold(wot_size=100, total_votes=-1)
|
||||
|
||||
def test_invalid_majority_pct(self):
|
||||
with pytest.raises(ValueError, match="majority_pct"):
|
||||
wot_threshold(wot_size=100, total_votes=10, majority_pct=150)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smith threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSmithThreshold:
|
||||
"""Test Smith sub-WoT threshold: ceil(smith_size ^ S)."""
|
||||
|
||||
def test_smith_size_20_exponent_01(self):
|
||||
"""smith_size=20, exponent=0.1 => ceil(20^0.1)."""
|
||||
result = smith_threshold(smith_wot_size=20, exponent=0.1)
|
||||
expected = math.ceil(20 ** 0.1)
|
||||
assert result == expected
|
||||
# 20^0.1 ~ 1.35, ceil => 2
|
||||
assert result == 2
|
||||
|
||||
def test_smith_size_1(self):
|
||||
"""smith_size=1 => ceil(1^0.1) = 1."""
|
||||
assert smith_threshold(smith_wot_size=1, exponent=0.1) == 1
|
||||
|
||||
def test_smith_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
smith_threshold(smith_wot_size=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TechComm threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTechcommThreshold:
|
||||
"""Test TechComm threshold: ceil(cotec_size ^ T)."""
|
||||
|
||||
def test_cotec_size_5_exponent_01(self):
|
||||
"""cotec_size=5, exponent=0.1 => ceil(5^0.1)."""
|
||||
result = techcomm_threshold(cotec_size=5, exponent=0.1)
|
||||
expected = math.ceil(5 ** 0.1)
|
||||
assert result == expected
|
||||
# 5^0.1 ~ 1.175, ceil => 2
|
||||
assert result == 2
|
||||
|
||||
def test_cotec_size_1(self):
|
||||
assert techcomm_threshold(cotec_size=1, exponent=0.1) == 1
|
||||
|
||||
def test_cotec_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
techcomm_threshold(cotec_size=0)
|
||||
Reference in New Issue
Block a user