feat: améliorations UX roadmap votes #94

- Votes cappés à VOTES_PER_WEEK (pas d'accumulation) pour valoriser
  les connexions régulières
- Solde de votes affiché dans la liste (input inéditable) à la place
  du bandeau Alert, avec date de prochain rechargement
- Compteur de votes par feature affiché comme input inéditable en
  fin de ligne (avant le bouton "+")
- Animation FLIP JS (roadmap_flip.js) : seules les lignes qui changent
  de position sont animées après un vote
- Renommage votes_credited_until → votes_last_credited_at (migration 0005)
- Constantes du module utilisées dans les assertions de tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-30 16:39:13 +02:00
parent f04e1b4c44
commit c4aa90b0d1
14 changed files with 286 additions and 102 deletions
+5
View File
@@ -968,6 +968,11 @@ input[type="number"] {
display: none; display: none;
} }
/* Roadmap */
.roadmap-vote-list input[type="text"] {
}
/* --- Bascule desktop / mobile au point de rupture 768 px --- */ /* --- Bascule desktop / mobile au point de rupture 768 px --- */
@media (max-width: 768px) { @media (max-width: 768px) {
+67
View File
@@ -0,0 +1,67 @@
/* FLIP animation for #roadmap-vote-list — only animates items that move */
(function () {
var firstPositions = {};
var debounceTimer = null;
var listObserver = null;
var observedList = null;
function getKey(el) {
var a = el.querySelector("a");
return a ? a.getAttribute("href") : el.textContent.trim().slice(0, 40);
}
function capturePositions() {
var list = document.getElementById("roadmap-vote-list");
if (!list) return;
firstPositions = {};
list.querySelectorAll(".list-group-item").forEach(function (el) {
firstPositions[getKey(el)] = el.getBoundingClientRect().top;
});
}
function applyFlip() {
var list = document.getElementById("roadmap-vote-list");
if (!list || Object.keys(firstPositions).length === 0) return;
list.querySelectorAll(".list-group-item").forEach(function (el) {
var key = getKey(el);
var first = firstPositions[key];
if (first === undefined) return;
var dy = first - el.getBoundingClientRect().top;
if (Math.abs(dy) < 2) return;
el.style.transition = "none";
el.style.transform = "translateY(" + dy + "px)";
void el.offsetWidth;
el.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
el.style.transform = "";
});
firstPositions = {};
}
function attachObserver() {
var list = document.getElementById("roadmap-vote-list");
if (!list || list === observedList) return;
if (listObserver) listObserver.disconnect();
observedList = list;
listObserver = new MutationObserver(function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(applyFlip, 50);
});
listObserver.observe(list, {
childList: true,
subtree: true,
characterData: true,
});
}
document.addEventListener(
"click",
function (e) {
if (e.target.closest('[id*="roadmap-vote"]')) capturePositions();
},
true
);
setInterval(attachObserver, 1000);
attachObserver();
})();
+11 -5
View File
@@ -25,8 +25,12 @@ _MIGRATIONS: list[tuple[str, str]] = [
"ALTER TABLE subscriptions ADD COLUMN votes_balance INTEGER NOT NULL DEFAULT 0", "ALTER TABLE subscriptions ADD COLUMN votes_balance INTEGER NOT NULL DEFAULT 0",
), ),
( (
"0004_add_votes_credited_until_to_subscriptions", "0004_add_votes_last_credited_at_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN votes_credited_until TEXT", "ALTER TABLE subscriptions ADD COLUMN votes_last_credited_at TEXT",
),
(
"0005_rename_votes_credited_until_to_votes_last_credited_at",
"ALTER TABLE subscriptions RENAME COLUMN votes_credited_until TO votes_last_credited_at",
), ),
] ]
@@ -46,9 +50,11 @@ def apply_pending() -> None:
try: try:
conn.execute(sql) conn.execute(sql)
except sqlite3.OperationalError as exc: except sqlite3.OperationalError as exc:
# SQLite ne supporte pas ALTER TABLE … ADD COLUMN IF NOT EXISTS. # Sur une DB fraîche (schéma déjà à jour), certaines migrations
# Sur une DB fraîche (schéma déjà à jour), on ignore l'erreur. # sont sans effet : ADD COLUMN → "duplicate column name",
if "duplicate column name" not in str(exc): # RENAME COLUMN → "no such column". On les ignore.
err = str(exc)
if "duplicate column name" not in err and "no such column" not in err:
raise raise
conn.execute( conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)", "INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
+16 -6
View File
@@ -21,27 +21,37 @@ def layout(**_):
if guard is not None: if guard is not None:
return guard return guard
balance = subs_db.credit_pending(current_user.id) balance = subs_db.credit_pending(current_user.id)
next_recharge = subs_db.next_recharge_at(current_user.id)
return account_shell( return account_shell(
"roadmap", roadmap_ui.roadmap_content(editable=True, balance=balance) "roadmap",
roadmap_ui.roadmap_content(
editable=True, balance=balance, next_recharge=next_recharge
),
) )
@callback( @callback(
Output("roadmap-vote-list", "children"), Output("roadmap-vote-list", "children"),
Output("roadmap-balance", "children"),
Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"), Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def cast_vote(n_clicks): def cast_vote(n_clicks):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return no_update, no_update return no_update
if not ctx.triggered_id or not any(n_clicks): if not ctx.triggered_id or not any(n_clicks):
return no_update, no_update return no_update
issue_number = ctx.triggered_id["index"] issue_number = ctx.triggered_id["index"]
if subs_db.spend_vote(current_user.id): if subs_db.spend_vote(current_user.id):
roadmap_db.record_vote(current_user.id, issue_number) roadmap_db.record_vote(current_user.id, issue_number)
balance = subs_db.credit_pending(current_user.id) balance = subs_db.credit_pending(current_user.id)
next_recharge = subs_db.next_recharge_at(current_user.id)
issues = github.fetch_roadmap_issues() issues = github.fetch_roadmap_issues()
counts = roadmap_db.vote_counts() counts = roadmap_db.vote_counts()
items = roadmap_ui.vote_items(issues["au_vote"], counts, editable=True) return roadmap_ui.vote_items(
return items, roadmap_ui.balance_text(balance) issues["au_vote"],
counts,
editable=True,
can_vote=balance > 0,
balance=balance,
next_recharge=next_recharge,
)
+86 -28
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
@@ -9,11 +10,6 @@ from src.roadmap import github
_CHANGELOG_PATH = Path(__file__).resolve().parents[2] / "CHANGELOG.md" _CHANGELOG_PATH = Path(__file__).resolve().parents[2] / "CHANGELOG.md"
def balance_text(balance: int) -> str:
mot = "vote" if balance == 1 else "votes"
return f"Il te reste {balance} {mot}."
def changelog_markdown() -> dcc.Markdown: def changelog_markdown() -> dcc.Markdown:
try: try:
text = _CHANGELOG_PATH.read_text(encoding="utf-8") text = _CHANGELOG_PATH.read_text(encoding="utf-8")
@@ -22,30 +18,83 @@ def changelog_markdown() -> dcc.Markdown:
return dcc.Markdown(text) return dcc.Markdown(text)
def _vote_item(issue: dict, count: int, editable: bool): def _vote_item(issue: dict, count: int, editable: bool, can_vote: bool = True):
mot = "vote" if count == 1 else "votes" right = [
children = [ dcc.Input(
html.A(issue["title"], href=issue["html_url"], target="_blank"), value=str(count),
html.Span(f"{count} {mot}", className="text-muted ms-1"), disabled=True,
type="text",
style={"width": "3rem", "textAlign": "right"},
className="ms-5 flex-shrink-0",
)
] ]
if editable: if editable:
children.append( right.append(
dbc.Button( dbc.Button(
"Voter", "+",
id={"type": "roadmap-vote", "index": issue["number"]}, id={"type": "roadmap-vote", "index": issue["number"]},
size="sm", size="sm",
color="primary", color="primary" if can_vote else "secondary",
className="ms-2", disabled=not can_vote,
className="ms-2 flex-shrink-0",
) )
) )
return dbc.ListGroupItem(children) return dbc.ListGroupItem(
[
html.A(issue["title"], href=issue["html_url"], target="_blank"),
html.Span(right, className="d-flex align-items-center"),
],
className="d-flex justify-content-between align-items-center",
)
def vote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list: def _balance_item(
balance: int, next_recharge: datetime | None = None
) -> dbc.ListGroupItem:
label_parts = [html.Span("Votes restants", style={"fontWeight": "bold"})]
if next_recharge is not None:
label_parts.append(
html.Span(
f" — rechargement le {next_recharge.strftime('%d/%m/%y à %H:%M')}",
className="text-muted small",
)
)
return dbc.ListGroupItem(
[
html.Span(label_parts),
dcc.Input(
value=str(balance),
disabled=True,
type="text",
style={"width": "3rem", "textAlign": "right"},
className="ms-3 ",
),
],
className="d-flex justify-content-between align-items-center",
)
def vote_items(
au_vote: list[dict],
counts: dict[int, int],
editable: bool,
can_vote: bool = True,
balance: int | None = None,
next_recharge: datetime | None = None,
) -> list:
result = (
[_balance_item(balance, next_recharge)]
if editable and balance is not None
else []
)
ordered = sorted(au_vote, key=lambda i: counts.get(i["number"], 0), reverse=True) ordered = sorted(au_vote, key=lambda i: counts.get(i["number"], 0), reverse=True)
if not ordered: if not ordered:
return [dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")] return result + [
return [_vote_item(i, counts.get(i["number"], 0), editable) for i in ordered] dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")
]
return result + [
_vote_item(i, counts.get(i["number"], 0), editable, can_vote) for i in ordered
]
def _en_cours_items(en_cours: list[dict]) -> list: def _en_cours_items(en_cours: list[dict]) -> list:
@@ -57,7 +106,9 @@ def _en_cours_items(en_cours: list[dict]) -> list:
] ]
def roadmap_content(editable: bool, balance: int | None = None) -> html.Div: def roadmap_content(
editable: bool, balance: int | None = None, next_recharge: datetime | None = None
) -> html.Div:
try: try:
issues = github.fetch_roadmap_issues() issues = github.fetch_roadmap_issues()
counts = roadmap_db.vote_counts() counts = roadmap_db.vote_counts()
@@ -66,21 +117,28 @@ def roadmap_content(editable: bool, balance: int | None = None) -> html.Div:
counts = {} counts = {}
body: list = [] body: list = []
if editable and balance is not None: body.append(html.H3("En cours de développement", className="mt-3"))
body.append(
dbc.Alert(balance_text(balance), color="info", id="roadmap-balance")
)
body.append(html.H3("En cours", className="mt-3"))
body.append(dbc.ListGroup(_en_cours_items(issues["en_cours"]), className="mb-4")) body.append(dbc.ListGroup(_en_cours_items(issues["en_cours"]), className="mb-4"))
can_vote = editable and balance is not None and balance > 0
body.append(html.H3("Au vote")) body.append(html.H3("Au vote"))
body.append( body.append(
dbc.ListGroup( html.Div(
vote_items(issues["au_vote"], counts, editable), dbc.ListGroup(
id="roadmap-vote-list", vote_items(
issues["au_vote"],
counts,
editable,
can_vote,
balance=balance,
next_recharge=next_recharge,
),
id="roadmap-vote-list",
),
className="mb-4", className="mb-4",
style={"width": "fit-content"},
) )
) )
body.append(html.Hr()) body.append(html.Hr())
body.append(html.H3("Changelog")) body.append(html.H3("Historique des versions"))
body.append(changelog_markdown()) body.append(changelog_markdown())
return html.Div(body) return html.Div(body)
+21 -10
View File
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
current_period_end TEXT, current_period_end TEXT,
trial_used INTEGER NOT NULL DEFAULT 0, trial_used INTEGER NOT NULL DEFAULT 0,
votes_balance INTEGER NOT NULL DEFAULT 0, votes_balance INTEGER NOT NULL DEFAULT 0,
votes_credited_until TEXT, votes_last_credited_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -30,7 +30,8 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
INITIAL_VOTES = 2 INITIAL_VOTES = 3
VOTES_PER_WEEK = 3
WEEK_SECONDS = 7 * 24 * 3600 WEEK_SECONDS = 7 * 24 * 3600
@@ -80,11 +81,11 @@ def freeze_votes_cursor(user_id: int) -> None:
les +2 initiaux restent gérés par credit_pending. Ne re-crédite jamais. les +2 initiaux restent gérés par credit_pending. Ne re-crédite jamais.
""" """
row = get_by_user(user_id) row = get_by_user(user_id)
if row is None or row["votes_credited_until"] is None: if row is None or row["votes_last_credited_at"] is None:
return return
now = _now() now = _now()
get_conn().execute( get_conn().execute(
"UPDATE subscriptions SET votes_credited_until = ?, updated_at = ? " "UPDATE subscriptions SET votes_last_credited_at = ?, updated_at = ? "
"WHERE user_id = ?", "WHERE user_id = ?",
(now, now, user_id), (now, now, user_id),
) )
@@ -154,7 +155,7 @@ def has_used_trial(user_id: int) -> bool:
def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None: def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None:
get_conn().execute( get_conn().execute(
"UPDATE subscriptions SET votes_balance = ?, votes_credited_until = ?, " "UPDATE subscriptions SET votes_balance = ?, votes_last_credited_at = ?, "
"updated_at = ? WHERE user_id = ?", "updated_at = ? WHERE user_id = ?",
(balance, cursor_iso, _now(), user_id), (balance, cursor_iso, _now(), user_id),
) )
@@ -163,8 +164,9 @@ def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None:
def credit_pending(user_id: int) -> int: def credit_pending(user_id: int) -> int:
"""Crédite paresseusement les votes acquis et renvoie le solde courant. """Crédite paresseusement les votes acquis et renvoie le solde courant.
+2 à la première activation (fin d'essai), puis +1 par semaine pleine tant +VOTES_PER_WEEK à la première activation, puis +VOTES_PER_WEEK par semaine
que l'abonnement est actif. Idempotent : ne crédite que des semaines pleines. pleine. Le solde est cappé à VOTES_PER_WEEK (pas d'accumulation).
Idempotent : ne crédite que des semaines pleines.
""" """
row = get_by_user(user_id) row = get_by_user(user_id)
if row is None: if row is None:
@@ -173,15 +175,15 @@ def credit_pending(user_id: int) -> int:
if row["status"] != "active": if row["status"] != "active":
return balance return balance
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cursor = row["votes_credited_until"] cursor = row["votes_last_credited_at"]
if cursor is None: if cursor is None:
balance += INITIAL_VOTES balance = min(balance + INITIAL_VOTES, VOTES_PER_WEEK)
_set_votes(user_id, balance, now.isoformat()) _set_votes(user_id, balance, now.isoformat())
return balance return balance
cur = datetime.fromisoformat(cursor) cur = datetime.fromisoformat(cursor)
weeks = int((now - cur).total_seconds() // WEEK_SECONDS) weeks = int((now - cur).total_seconds() // WEEK_SECONDS)
if weeks > 0: if weeks > 0:
balance += weeks balance = min(balance + weeks * VOTES_PER_WEEK, VOTES_PER_WEEK)
new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS) new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS)
_set_votes(user_id, balance, new_cursor.isoformat()) _set_votes(user_id, balance, new_cursor.isoformat())
return balance return balance
@@ -195,3 +197,12 @@ def spend_vote(user_id: int) -> bool:
(_now(), user_id), (_now(), user_id),
) )
return cur.rowcount > 0 return cur.rowcount > 0
def next_recharge_at(user_id: int) -> datetime | None:
"""Retourne la date du prochain rechargement de votes, ou None si non applicable."""
row = get_by_user(user_id)
if not row or not row["votes_last_credited_at"]:
return None
cursor = datetime.fromisoformat(row["votes_last_credited_at"])
return cursor + timedelta(seconds=WEEK_SECONDS)
+17 -3
View File
@@ -49,6 +49,9 @@ os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
os.environ.pop("DATA_SCHEMA_PATH", None) os.environ.pop("DATA_SCHEMA_PATH", None)
_DB_BACKUP = _DB_PATH.with_suffix(".duckdb.pytest-backup")
def _cleanup_db_artifacts() -> None: def _cleanup_db_artifacts() -> None:
for artifact in ( for artifact in (
_DB_PATH, _DB_PATH,
@@ -59,19 +62,30 @@ def _cleanup_db_artifacts() -> None:
artifact.unlink() artifact.unlink()
def _backup_db() -> None:
if _DB_PATH.exists():
_DB_PATH.rename(_DB_BACKUP)
def _restore_db() -> None:
_cleanup_db_artifacts()
if _DB_BACKUP.exists():
_DB_BACKUP.rename(_DB_PATH)
# Runs at conftest import, before test modules import src.db (which builds the # Runs at conftest import, before test modules import src.db (which builds the
# DuckDB at import time). Guarantees the test parquet exists and the stale DB # DuckDB at import time). Guarantees the test parquet exists and the stale DB
# from a previous `python run.py` is wiped so src.db rebuilds from test data. # from a previous `python run.py` is wiped so src.db rebuilds from test data.
pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH) pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH)
_backup_db()
_cleanup_db_artifacts() _cleanup_db_artifacts()
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def test_data(): def test_data():
yield str(_PARQUET_PATH) yield str(_PARQUET_PATH)
# Teardown: remove the test DuckDB so the next `python run.py` rebuilds # Teardown: restore the original DuckDB so the next `python run.py` finds it.
# from decp_prod.parquet. _restore_db()
_cleanup_db_artifacts()
def pytest_setup_options(): def pytest_setup_options():
+9 -5
View File
@@ -1,10 +1,14 @@
import pytest import pytest
# Dash minimal pour que register_page() fonctionne dans les tests de pages de ce # Importer l'app complète à la collecte : sa découverte use_pages enregistre
# répertoire (CONFIG peuplé). Instancié une seule fois, ici, avant tout import de page. # chaque page (et ses @callback) exactement une fois, en contexte propre. Un Dash
from dash import Dash as _Dash # minimal ad hoc suivi d'imports de pages dans les tests provoque un double
# enregistrement des @callback (Dash ré-exécute chaque page via exec_module
_Dash(__name__, use_pages=True, pages_folder="", assets_folder="assets") # pendant la découverte de src.app), d'où "Duplicate callback outputs" qui casse
# le rendu de toutes les pages dans la suite Selenium. En important src.app ici,
# la découverte tourne en premier et les imports ultérieurs de pages sont mis en
# cache. Voir aussi tests/subscriptions/conftest.py.
from src.app import app # noqa: F401, E402
@pytest.fixture @pytest.fixture
+4 -8
View File
@@ -3,11 +3,6 @@ from dash import dcc, html
from src.roadmap import ui from src.roadmap import ui
def test_balance_text_singular_plural():
assert ui.balance_text(1) == "Il te reste 1 vote."
assert ui.balance_text(3) == "Il te reste 3 votes."
def test_vote_items_sorted_by_count_desc(): def test_vote_items_sorted_by_count_desc():
au_vote = [ au_vote = [
{"number": 1, "title": "A", "html_url": "u1"}, {"number": 1, "title": "A", "html_url": "u1"},
@@ -22,8 +17,8 @@ def test_vote_items_sorted_by_count_desc():
def test_vote_items_buttons_only_when_editable(): def test_vote_items_buttons_only_when_editable():
au_vote = [{"number": 1, "title": "A", "html_url": "u1"}] au_vote = [{"number": 1, "title": "A", "html_url": "u1"}]
assert "Voter" in str(ui.vote_items(au_vote, {1: 0}, editable=True)) assert "roadmap-vote" in str(ui.vote_items(au_vote, {1: 0}, editable=True))
assert "Voter" not in str(ui.vote_items(au_vote, {1: 0}, editable=False)) assert "roadmap-vote" not in str(ui.vote_items(au_vote, {1: 0}, editable=False))
def test_changelog_markdown_returns_component(): def test_changelog_markdown_returns_component():
@@ -47,4 +42,5 @@ def test_roadmap_content_renders(monkeypatch):
assert isinstance(content, html.Div) assert isinstance(content, html.Div)
assert "En cours X" in s assert "En cours X" in s
assert "Au vote Y" in s assert "Au vote Y" in s
assert "Il te reste 2 votes." in s assert "Votes restants" in s
assert "value='2'" in s
+16 -12
View File
@@ -2,13 +2,17 @@ import json as _json
import pytest import pytest
# Initialise un Dash minimal pour que register_page() fonctionne dans les tests # Importer l'app complète à la collecte : sa découverte use_pages enregistre
# unitaires de pages (pas besoin du serveur complet — juste que CONFIG soit peuplé). # chaque page (et ses @callback) exactement une fois, en contexte propre.
from dash import Dash as _Dash #
# Ne PAS créer un Dash minimal ad hoc puis importer la page séparément : Dash
_Dash(__name__, use_pages=True, pages_folder="", assets_folder="assets") # ré-exécute chaque module de page via exec_module pendant la découverte (sans
# vérifier sys.modules). Si la page a déjà été importée (donc ses @callback déjà
from src.pages import compte_abonnement # noqa: F401, E402 # doit venir après Dash() # enregistrés), elle est ré-enregistrée → "Duplicate callback outputs"
# (salaire-modal, resiliation-modal) qui casse le rendu de TOUTES les pages dans
# la suite Selenium complète. En important src.app ici, la découverte tourne en
# premier et les imports ultérieurs de compte_abonnement sont mis en cache.
from src.app import app # noqa: F401, E402
class FakeResponse: class FakeResponse:
@@ -67,11 +71,11 @@ def sub_app(users_db_path, monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t")
app = Flask(__name__) flask_app = Flask(__name__)
app.config["WTF_CSRF_ENABLED"] = False flask_app.config["WTF_CSRF_ENABLED"] = False
init_auth(app) init_auth(flask_app)
init_subscriptions(app) init_subscriptions(flask_app)
return app return flask_app
@pytest.fixture @pytest.fixture
+16 -16
View File
@@ -20,7 +20,7 @@ def _make_user(email="u@ex.fr"):
def _activate(uid, cursor_iso=None): def _activate(uid, cursor_iso=None):
"""Met l'abonnement en statut actif avec un curseur d'accumulation donné.""" """Met l'abonnement en statut actif avec un curseur d'accumulation donné."""
db.get_conn().execute( db.get_conn().execute(
"UPDATE subscriptions SET status = 'active', votes_credited_until = ? " "UPDATE subscriptions SET status = 'active', votes_last_credited_at = ? "
"WHERE user_id = ?", "WHERE user_id = ?",
(cursor_iso, uid), (cursor_iso, uid),
) )
@@ -135,7 +135,7 @@ def test_init_schema_creates_votes_columns(users_db_path):
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
row = db.get_by_user(uid) row = db.get_by_user(uid)
assert row["votes_balance"] == 0 assert row["votes_balance"] == 0
assert row["votes_credited_until"] is None assert row["votes_last_credited_at"] is None
def test_credit_pending_grants_initial_two_on_first_active(users_db_path): def test_credit_pending_grants_initial_two_on_first_active(users_db_path):
@@ -144,8 +144,8 @@ def test_credit_pending_grants_initial_two_on_first_active(users_db_path):
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None) _activate(uid, cursor_iso=None)
balance = db.credit_pending(uid) balance = db.credit_pending(uid)
assert balance == 2 assert balance == db.INITIAL_VOTES
assert db.get_by_user(uid)["votes_credited_until"] is not None assert db.get_by_user(uid)["votes_last_credited_at"] is not None
def test_credit_pending_is_idempotent_same_day(users_db_path): def test_credit_pending_is_idempotent_same_day(users_db_path):
@@ -154,17 +154,17 @@ def test_credit_pending_is_idempotent_same_day(users_db_path):
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None) _activate(uid, cursor_iso=None)
db.credit_pending(uid) db.credit_pending(uid)
assert db.credit_pending(uid) == 2 # aucun crédit supplémentaire assert db.credit_pending(uid) == db.INITIAL_VOTES # aucun crédit supplémentaire
def test_credit_pending_adds_one_vote_per_full_week(users_db_path): def test_credit_pending_capped_at_votes_per_week(users_db_path):
db.init_schema() db.init_schema()
uid = _make_user() uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat() fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat()
_activate(uid, cursor_iso=fifteen_days_ago) _activate(uid, cursor_iso=fifteen_days_ago)
# 15 jours = 2 semaines pleines # 15 jours = 2 semaines pleines, mais le solde est cappé à VOTES_PER_WEEK
assert db.credit_pending(uid) == 2 assert db.credit_pending(uid) == db.VOTES_PER_WEEK
def test_credit_pending_no_credit_when_not_active(users_db_path): def test_credit_pending_no_credit_when_not_active(users_db_path):
@@ -179,9 +179,9 @@ def test_spend_vote_decrements_when_balance_positive(users_db_path):
uid = _make_user() uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None) _activate(uid, cursor_iso=None)
db.credit_pending(uid) # solde = 2 db.credit_pending(uid) # solde = INITIAL_VOTES
assert db.spend_vote(uid) is True assert db.spend_vote(uid) is True
assert db.get_by_user(uid)["votes_balance"] == 1 assert db.get_by_user(uid)["votes_balance"] == db.INITIAL_VOTES - 1
def test_spend_vote_refused_when_balance_zero(users_db_path): def test_spend_vote_refused_when_balance_zero(users_db_path):
@@ -198,21 +198,21 @@ def test_reactivation_resets_cursor_without_regranting(users_db_path):
uid = _make_user() uid = _make_user()
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
_activate(uid, cursor_iso=None) _activate(uid, cursor_iso=None)
db.credit_pending(uid) # +2, curseur posé db.credit_pending(uid) # +3, curseur posé
# désabonnement # désabonnement
db.update_from_webhook("decpinfo-1", "sub_1", "cancelled", _future()) db.update_from_webhook("decpinfo-1", "sub_1", "cancelled", _future())
# période sans abonnement simulée : on recule artificiellement le curseur # période sans abonnement simulée : on recule artificiellement le curseur
old_cursor = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() old_cursor = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
db.get_conn().execute( db.get_conn().execute(
"UPDATE subscriptions SET votes_credited_until = ? WHERE user_id = ?", "UPDATE subscriptions SET votes_last_credited_at = ? WHERE user_id = ?",
(old_cursor, uid), (old_cursor, uid),
) )
# réabonnement # réabonnement
db.update_from_webhook("decpinfo-1", "sub_1", "active", _future()) db.update_from_webhook("decpinfo-1", "sub_1", "active", _future())
row = db.get_by_user(uid) row = db.get_by_user(uid)
assert row["votes_balance"] == 2 # pas de re-crédit des +2 assert row["votes_balance"] == db.INITIAL_VOTES # pas de re-crédit des +3
# le curseur a été remis ~à maintenant → pas de crédit du gap de 30 jours # le curseur a été remis ~à maintenant → pas de crédit du gap de 30 jours
assert db.credit_pending(uid) == 2 assert db.credit_pending(uid) == db.INITIAL_VOTES
def test_trial_to_active_does_not_reset_then_grants_two(users_db_path): def test_trial_to_active_does_not_reset_then_grants_two(users_db_path):
@@ -221,5 +221,5 @@ def test_trial_to_active_does_not_reset_then_grants_two(users_db_path):
db.create_pending(uid, "decpinfo-1", "simple") db.create_pending(uid, "decpinfo-1", "simple")
db.update_from_webhook("decpinfo-1", "sub_1", "trial", _future()) db.update_from_webhook("decpinfo-1", "sub_1", "trial", _future())
db.update_from_webhook("decpinfo-1", "sub_1", "active", _future()) db.update_from_webhook("decpinfo-1", "sub_1", "active", _future())
# fin d'essai : credit_pending accorde les +2 initiaux # fin d'essai : credit_pending accorde les +INITIAL_VOTES initiaux
assert db.credit_pending(uid) == 2 assert db.credit_pending(uid) == db.INITIAL_VOTES
+10 -2
View File
@@ -35,10 +35,18 @@ def test_fill_csrf_inputs_allows_initial_call():
initiale (_pages_location → _generate_csrf_token → csrf-token → _fill_csrf_inputs), initiale (_pages_location → _generate_csrf_token → csrf-token → _fill_csrf_inputs),
laissant le champ csrf_token vide → erreur 400 au premier chargement de /connexion. laissant le champ csrf_token vide → erreur 400 au premier chargement de /connexion.
""" """
import src.app # noqa: F401 — enregistre les callbacks from src.app import app # enregistre les callbacks et expose callback_map
# Tant que l'app n'a pas été servie, les callbacks vivent dans
# dash._callback.GLOBAL_CALLBACK_MAP. Dès la première requête (qu'un test
# précédent a pu déclencher), Dash les déplace dans app.callback_map et vide
# le registre global. On inspecte donc les deux pour ne pas dépendre de
# l'ordre des tests.
found = False found = False
for cb_info in dash._callback.GLOBAL_CALLBACK_MAP.values(): registries = list(dash._callback.GLOBAL_CALLBACK_MAP.values()) + list(
app.callback_map.values()
)
for cb_info in registries:
inputs = getattr(cb_info, "inputs", None) or cb_info.get("inputs", []) inputs = getattr(cb_info, "inputs", None) or cb_info.get("inputs", [])
for inp in inputs: for inp in inputs:
if hasattr(inp, "component_id"): if hasattr(inp, "component_id"):
+8 -7
View File
@@ -45,7 +45,7 @@ def test_002_filter_persistence(dash_duo: DashComposite):
def open_page_and_check_filter_input(): def open_page_and_check_filter_input():
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
filter_input_selector = ( filter_input_selector = (
'.marches_table th[data-dash-column="uid"] input[type="text"]' '.marches_table th[data-dash-column="dateNotification"] input[type="text"]'
) )
dash_duo.wait_for_element(filter_input_selector, timeout=2) dash_duo.wait_for_element(filter_input_selector, timeout=2)
_filter_input: WebElement = dash_duo.find_element(filter_input_selector) _filter_input: WebElement = dash_duo.find_element(filter_input_selector)
@@ -53,17 +53,17 @@ def test_002_filter_persistence(dash_duo: DashComposite):
for page in ["tableau", "acheteurs/123", "titulaires/345"]: for page in ["tableau", "acheteurs/123", "titulaires/345"]:
filter_input = open_page_and_check_filter_input() filter_input = open_page_and_check_filter_input()
filter_input.send_keys("11") # a UID that doesn't exist filter_input.send_keys("11") # valeur quelconque, on teste la persistance
filter_input.send_keys(Keys.ENTER) filter_input.send_keys(Keys.ENTER)
filter_input = open_page_and_check_filter_input() filter_input = open_page_and_check_filter_input()
assert filter_input.get_attribute("value") == "11" assert filter_input.get_attribute("value") == "11"
def test_003_tableau_download(dash_duo: DashComposite): def test_003_tableau_download(dash_duo: DashComposite):
from pages.acheteur import download_acheteur_data
from pages.tableau import download_data
from pages.titulaire import download_titulaire_data
from src.app import app from src.app import app
from src.pages.acheteur import download_acheteur_data
from src.pages.tableau import download_data
from src.pages.titulaire import download_titulaire_data
# Juste pour instancier l'app # Juste pour instancier l'app
print(app.server.name) print(app.server.name)
@@ -340,5 +340,6 @@ def test_015_tableau_filter_date(dash_duo: DashComposite):
_filter_input: WebElement = dash_duo.find_element(filter_input) _filter_input: WebElement = dash_duo.find_element(filter_input)
_filter_input.send_keys("3333") # a dateNotification that doesn't exist _filter_input.send_keys("3333") # a dateNotification that doesn't exist
_filter_input.send_keys(Keys.ENTER) _filter_input.send_keys(Keys.ENTER)
_filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result) # Le filtrage est asynchrone : attendre la mise à jour du tableau plutôt
assert len(_filter_result) == 0, f"Page : {page}" # que de lire les lignes immédiatement (sinon on lit l'état pré-filtre).
dash_duo.wait_for_no_elements(filter_cell_result, timeout=4)
Binary file not shown.