From c4aa90b0d1be0a29e15e788b747996d91430c95b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 30 Jun 2026 16:39:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9liorations=20UX=20roadmap=20vot?= =?UTF-8?q?es=20#94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/assets/css/style.css | 5 ++ src/assets/roadmap_flip.js | 67 +++++++++++++++++++ src/migrations.py | 16 +++-- src/pages/compte_roadmap.py | 22 ++++-- src/roadmap/ui.py | 114 ++++++++++++++++++++++++-------- src/subscriptions/db.py | 31 ++++++--- tests/conftest.py | 20 +++++- tests/roadmap/conftest.py | 14 ++-- tests/roadmap/test_ui.py | 12 ++-- tests/subscriptions/conftest.py | 28 ++++---- tests/subscriptions/test_db.py | 32 ++++----- tests/test_csrf_architecture.py | 12 +++- tests/test_main.py | 15 +++-- tests/users.test.sqlite | Bin 61440 -> 81920 bytes 14 files changed, 286 insertions(+), 102 deletions(-) create mode 100644 src/assets/roadmap_flip.js diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 2c50d97..d933c87 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -968,6 +968,11 @@ input[type="number"] { display: none; } +/* Roadmap */ + +.roadmap-vote-list input[type="text"] { +} + /* --- Bascule desktop / mobile au point de rupture 768 px --- */ @media (max-width: 768px) { diff --git a/src/assets/roadmap_flip.js b/src/assets/roadmap_flip.js new file mode 100644 index 0000000..4254df4 --- /dev/null +++ b/src/assets/roadmap_flip.js @@ -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(); +})(); diff --git a/src/migrations.py b/src/migrations.py index 11061e8..05cb933 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -25,8 +25,12 @@ _MIGRATIONS: list[tuple[str, str]] = [ "ALTER TABLE subscriptions ADD COLUMN votes_balance INTEGER NOT NULL DEFAULT 0", ), ( - "0004_add_votes_credited_until_to_subscriptions", - "ALTER TABLE subscriptions ADD COLUMN votes_credited_until TEXT", + "0004_add_votes_last_credited_at_to_subscriptions", + "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: conn.execute(sql) 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), on ignore l'erreur. - if "duplicate column name" not in str(exc): + # Sur une DB fraîche (schéma déjà à jour), certaines migrations + # sont sans effet : ADD COLUMN → "duplicate column name", + # 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 conn.execute( "INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)", diff --git a/src/pages/compte_roadmap.py b/src/pages/compte_roadmap.py index 98a5e9f..3ca1fe3 100644 --- a/src/pages/compte_roadmap.py +++ b/src/pages/compte_roadmap.py @@ -21,27 +21,37 @@ def layout(**_): if guard is not None: return guard balance = subs_db.credit_pending(current_user.id) + next_recharge = subs_db.next_recharge_at(current_user.id) 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( Output("roadmap-vote-list", "children"), - Output("roadmap-balance", "children"), Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"), prevent_initial_call=True, ) def cast_vote(n_clicks): if not current_user.is_authenticated: - return no_update, no_update + return no_update if not ctx.triggered_id or not any(n_clicks): - return no_update, no_update + return no_update issue_number = ctx.triggered_id["index"] if subs_db.spend_vote(current_user.id): roadmap_db.record_vote(current_user.id, issue_number) balance = subs_db.credit_pending(current_user.id) + next_recharge = subs_db.next_recharge_at(current_user.id) issues = github.fetch_roadmap_issues() counts = roadmap_db.vote_counts() - items = roadmap_ui.vote_items(issues["au_vote"], counts, editable=True) - return items, roadmap_ui.balance_text(balance) + return roadmap_ui.vote_items( + issues["au_vote"], + counts, + editable=True, + can_vote=balance > 0, + balance=balance, + next_recharge=next_recharge, + ) diff --git a/src/roadmap/ui.py b/src/roadmap/ui.py index 2148f84..54a0b37 100644 --- a/src/roadmap/ui.py +++ b/src/roadmap/ui.py @@ -1,3 +1,4 @@ +from datetime import datetime from pathlib import Path import dash_bootstrap_components as dbc @@ -9,11 +10,6 @@ from src.roadmap import github _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: try: text = _CHANGELOG_PATH.read_text(encoding="utf-8") @@ -22,30 +18,83 @@ def changelog_markdown() -> dcc.Markdown: return dcc.Markdown(text) -def _vote_item(issue: dict, count: int, editable: bool): - mot = "vote" if count == 1 else "votes" - children = [ - html.A(issue["title"], href=issue["html_url"], target="_blank"), - html.Span(f" — {count} {mot}", className="text-muted ms-1"), +def _vote_item(issue: dict, count: int, editable: bool, can_vote: bool = True): + right = [ + dcc.Input( + value=str(count), + disabled=True, + type="text", + style={"width": "3rem", "textAlign": "right"}, + className="ms-5 flex-shrink-0", + ) ] if editable: - children.append( + right.append( dbc.Button( - "Voter", + "+", id={"type": "roadmap-vote", "index": issue["number"]}, size="sm", - color="primary", - className="ms-2", + color="primary" if can_vote else "secondary", + 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) if not ordered: - return [dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")] - return [_vote_item(i, counts.get(i["number"], 0), editable) for i in ordered] + return result + [ + 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: @@ -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: issues = github.fetch_roadmap_issues() counts = roadmap_db.vote_counts() @@ -66,21 +117,28 @@ def roadmap_content(editable: bool, balance: int | None = None) -> html.Div: counts = {} body: list = [] - if editable and balance is not None: - body.append( - dbc.Alert(balance_text(balance), color="info", id="roadmap-balance") - ) - body.append(html.H3("En cours", className="mt-3")) + body.append(html.H3("En cours de développement", className="mt-3")) 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( - dbc.ListGroup( - vote_items(issues["au_vote"], counts, editable), - id="roadmap-vote-list", + html.Div( + dbc.ListGroup( + vote_items( + issues["au_vote"], + counts, + editable, + can_vote, + balance=balance, + next_recharge=next_recharge, + ), + id="roadmap-vote-list", + ), className="mb-4", + style={"width": "fit-content"}, ) ) body.append(html.Hr()) - body.append(html.H3("Changelog")) + body.append(html.H3("Historique des versions")) body.append(changelog_markdown()) return html.Div(body) diff --git a/src/subscriptions/db.py b/src/subscriptions/db.py index 52bb94e..818f11a 100644 --- a/src/subscriptions/db.py +++ b/src/subscriptions/db.py @@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS subscriptions ( current_period_end TEXT, trial_used 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, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -30,7 +30,8 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() -INITIAL_VOTES = 2 +INITIAL_VOTES = 3 +VOTES_PER_WEEK = 3 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. """ 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 now = _now() get_conn().execute( - "UPDATE subscriptions SET votes_credited_until = ?, updated_at = ? " + "UPDATE subscriptions SET votes_last_credited_at = ?, updated_at = ? " "WHERE 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: 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 = ?", (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: """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 - que l'abonnement est actif. Idempotent : ne crédite que des semaines pleines. + +VOTES_PER_WEEK à la première activation, puis +VOTES_PER_WEEK par semaine + 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) if row is None: @@ -173,15 +175,15 @@ def credit_pending(user_id: int) -> int: if row["status"] != "active": return balance now = datetime.now(timezone.utc) - cursor = row["votes_credited_until"] + cursor = row["votes_last_credited_at"] if cursor is None: - balance += INITIAL_VOTES + balance = min(balance + INITIAL_VOTES, VOTES_PER_WEEK) _set_votes(user_id, balance, now.isoformat()) return balance cur = datetime.fromisoformat(cursor) weeks = int((now - cur).total_seconds() // WEEK_SECONDS) if weeks > 0: - balance += weeks + balance = min(balance + weeks * VOTES_PER_WEEK, VOTES_PER_WEEK) new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS) _set_votes(user_id, balance, new_cursor.isoformat()) return balance @@ -195,3 +197,12 @@ def spend_vote(user_id: int) -> bool: (_now(), user_id), ) 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) diff --git a/tests/conftest.py b/tests/conftest.py index 1244851..a6e430a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,9 @@ os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE) os.environ.pop("DATA_SCHEMA_PATH", None) +_DB_BACKUP = _DB_PATH.with_suffix(".duckdb.pytest-backup") + + def _cleanup_db_artifacts() -> None: for artifact in ( _DB_PATH, @@ -59,19 +62,30 @@ def _cleanup_db_artifacts() -> None: 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 # 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. pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH) +_backup_db() _cleanup_db_artifacts() @pytest.fixture(scope="session", autouse=True) def test_data(): yield str(_PARQUET_PATH) - # Teardown: remove the test DuckDB so the next `python run.py` rebuilds - # from decp_prod.parquet. - _cleanup_db_artifacts() + # Teardown: restore the original DuckDB so the next `python run.py` finds it. + _restore_db() def pytest_setup_options(): diff --git a/tests/roadmap/conftest.py b/tests/roadmap/conftest.py index adff4b2..6b56473 100644 --- a/tests/roadmap/conftest.py +++ b/tests/roadmap/conftest.py @@ -1,10 +1,14 @@ import pytest -# Dash minimal pour que register_page() fonctionne dans les tests de pages de ce -# répertoire (CONFIG peuplé). Instancié une seule fois, ici, avant tout import de page. -from dash import Dash as _Dash - -_Dash(__name__, use_pages=True, pages_folder="", assets_folder="assets") +# Importer l'app complète à la collecte : sa découverte use_pages enregistre +# chaque page (et ses @callback) exactement une fois, en contexte propre. Un 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 +# 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 diff --git a/tests/roadmap/test_ui.py b/tests/roadmap/test_ui.py index 1e4565e..c7a4d23 100644 --- a/tests/roadmap/test_ui.py +++ b/tests/roadmap/test_ui.py @@ -3,11 +3,6 @@ from dash import dcc, html 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(): au_vote = [ {"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(): au_vote = [{"number": 1, "title": "A", "html_url": "u1"}] - assert "Voter" 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" in str(ui.vote_items(au_vote, {1: 0}, editable=True)) + assert "roadmap-vote" not in str(ui.vote_items(au_vote, {1: 0}, editable=False)) def test_changelog_markdown_returns_component(): @@ -47,4 +42,5 @@ def test_roadmap_content_renders(monkeypatch): assert isinstance(content, html.Div) assert "En cours X" 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 diff --git a/tests/subscriptions/conftest.py b/tests/subscriptions/conftest.py index 58e3ff2..1d93c4b 100644 --- a/tests/subscriptions/conftest.py +++ b/tests/subscriptions/conftest.py @@ -2,13 +2,17 @@ import json as _json import pytest -# Initialise un Dash minimal pour que register_page() fonctionne dans les tests -# unitaires de pages (pas besoin du serveur complet — juste que CONFIG soit peuplé). -from dash import Dash as _Dash - -_Dash(__name__, use_pages=True, pages_folder="", assets_folder="assets") - -from src.pages import compte_abonnement # noqa: F401, E402 # doit venir après Dash() +# Importer l'app complète à la collecte : sa découverte use_pages enregistre +# chaque page (et ses @callback) exactement une fois, en contexte propre. +# +# Ne PAS créer un Dash minimal ad hoc puis importer la page séparément : Dash +# 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à +# 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: @@ -67,11 +71,11 @@ def sub_app(users_db_path, monkeypatch): monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") - app = Flask(__name__) - app.config["WTF_CSRF_ENABLED"] = False - init_auth(app) - init_subscriptions(app) - return app + flask_app = Flask(__name__) + flask_app.config["WTF_CSRF_ENABLED"] = False + init_auth(flask_app) + init_subscriptions(flask_app) + return flask_app @pytest.fixture diff --git a/tests/subscriptions/test_db.py b/tests/subscriptions/test_db.py index 5bc87c3..75c7065 100644 --- a/tests/subscriptions/test_db.py +++ b/tests/subscriptions/test_db.py @@ -20,7 +20,7 @@ def _make_user(email="u@ex.fr"): def _activate(uid, cursor_iso=None): """Met l'abonnement en statut actif avec un curseur d'accumulation donné.""" db.get_conn().execute( - "UPDATE subscriptions SET status = 'active', votes_credited_until = ? " + "UPDATE subscriptions SET status = 'active', votes_last_credited_at = ? " "WHERE user_id = ?", (cursor_iso, uid), ) @@ -135,7 +135,7 @@ def test_init_schema_creates_votes_columns(users_db_path): db.create_pending(uid, "decpinfo-1", "simple") row = db.get_by_user(uid) 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): @@ -144,8 +144,8 @@ def test_credit_pending_grants_initial_two_on_first_active(users_db_path): db.create_pending(uid, "decpinfo-1", "simple") _activate(uid, cursor_iso=None) balance = db.credit_pending(uid) - assert balance == 2 - assert db.get_by_user(uid)["votes_credited_until"] is not None + assert balance == db.INITIAL_VOTES + assert db.get_by_user(uid)["votes_last_credited_at"] is not None 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") _activate(uid, cursor_iso=None) 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() uid = _make_user() db.create_pending(uid, "decpinfo-1", "simple") fifteen_days_ago = (datetime.now(timezone.utc) - timedelta(days=15)).isoformat() _activate(uid, cursor_iso=fifteen_days_ago) - # 15 jours = 2 semaines pleines - assert db.credit_pending(uid) == 2 + # 15 jours = 2 semaines pleines, mais le solde est cappé à VOTES_PER_WEEK + assert db.credit_pending(uid) == db.VOTES_PER_WEEK 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() db.create_pending(uid, "decpinfo-1", "simple") _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.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): @@ -198,21 +198,21 @@ def test_reactivation_resets_cursor_without_regranting(users_db_path): uid = _make_user() db.create_pending(uid, "decpinfo-1", "simple") _activate(uid, cursor_iso=None) - db.credit_pending(uid) # +2, curseur posé + db.credit_pending(uid) # +3, curseur posé # désabonnement db.update_from_webhook("decpinfo-1", "sub_1", "cancelled", _future()) # période sans abonnement simulée : on recule artificiellement le curseur old_cursor = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() 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), ) # réabonnement db.update_from_webhook("decpinfo-1", "sub_1", "active", _future()) 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 - 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): @@ -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.update_from_webhook("decpinfo-1", "sub_1", "trial", _future()) db.update_from_webhook("decpinfo-1", "sub_1", "active", _future()) - # fin d'essai : credit_pending accorde les +2 initiaux - assert db.credit_pending(uid) == 2 + # fin d'essai : credit_pending accorde les +INITIAL_VOTES initiaux + assert db.credit_pending(uid) == db.INITIAL_VOTES diff --git a/tests/test_csrf_architecture.py b/tests/test_csrf_architecture.py index 17f8064..2c6882f 100644 --- a/tests/test_csrf_architecture.py +++ b/tests/test_csrf_architecture.py @@ -35,10 +35,18 @@ def test_fill_csrf_inputs_allows_initial_call(): initiale (_pages_location → _generate_csrf_token → csrf-token → _fill_csrf_inputs), 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 - 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", []) for inp in inputs: if hasattr(inp, "component_id"): diff --git a/tests/test_main.py b/tests/test_main.py index 9070ba4..f9f0179 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -45,7 +45,7 @@ def test_002_filter_persistence(dash_duo: DashComposite): def open_page_and_check_filter_input(): dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") 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) _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"]: 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 = open_page_and_check_filter_input() assert filter_input.get_attribute("value") == "11" 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.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 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.send_keys("3333") # a dateNotification that doesn't exist _filter_input.send_keys(Keys.ENTER) - _filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result) - assert len(_filter_result) == 0, f"Page : {page}" + # Le filtrage est asynchrone : attendre la mise à jour du tableau plutôt + # 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) diff --git a/tests/users.test.sqlite b/tests/users.test.sqlite index a98af71e20d0382cf10ed964fb5baefb350c4991..07db7a411535c98c2261cd2b38f7e762d41cb97a 100644 GIT binary patch delta 1447 zcmbW1-%ry}6vubdAA|j9H#R9S4L2@k0|s_$$B${mDKlc`5ZDqD5=&t0)P0}nn-pLtMUjfu8pK*x_4Z`!2yobx^Bo=@)S(l=~r ziC7PK9X6ZIufGSYc_YGc4qMNQ-)`OH1IR|drdBD=&A2`}H=SZ*wefAE08qo1hHQhE z%#+>3I~?JyTN?m4o?(=o35CG(RHZ=d0t(L0sgfd0iv_VbBcTuE`M#QIrX=NMRmuxm zQI!iYo0`Z*YOaE)sKTLNR81#Za}Sjln(fS$5Lgb##k{m2=NE(rlBjAWN!YI=D~cx7 znfy1$Qps!z4rhi^6Yva$1L|ue1M8GwGnr7-W~Zf6$5VLD&fL~JTPdsJbfLaToz7RQ zm`&bDr|MI}uvtmYqb<^hB6zz?Q>2n0=Z*Rhy=-J$)}p%MB7n$4_|dxiT3PeknRpN^ zw;wjIi1RwYd0Bd_2u3E$zUf5Y7N;3}(6q%Kt{o==%^roKI4gC;eIzp+1U59|YM!zL z3sK9)yVBWYXM#bn8Zf06m*vw_hsNSK0<%qAKz+F1X#rz!i60#Zn%4nlMzS!IOQ+#b z>Q*wB&ccuxB5I+C*_m;6^pgw_k2Ipucb3XYDXmt=C6k?3kcqtqot(B%r}aD&QsY#ES)4w4z~Hl3Svu=1y&6QD^~x^mqa z0BFZOUU}sT12j*Kz4725JtWm>quuILS_ep#i++|rdE|esrXzfxT zPJ%fpf;v~TA0Lw#8|&?6d%HO{8|4!mpJ3UZe!j1-pYLK>eKSsAvqpMe5cB%2F$dF< z1J|#Th<2)NiCO|K5gX{?2jczF=zfAx9FvbHs6QO~Ty5nHc{IUu3BI?dKNjVCxqbN^ z44X9MoTdK3k3rY9oTWpH)m0tYo{j!WZ_|jLqPwU)9oa5b9@L3}|0hQB*sLKxgNOk{ zHFzu9v@%nYAFA?PQ6XZOTodIEIo4x1p~1VbNkel=>nCC{wEZKwc@J)S^vClLTHMp7 delta 247 zcmZo@U~PE7JV9EJje&uIABbUqf1-}DCL4oZ?=N2VHU>WC2Ml~=Jj-~`aoh7mbNu0N zVSd0Z!nL0@iOZOumCtyypg;uk=KGvYj9e{>eC*=t>Wm$Xo98lKXPjKj>deBW##jba zQpC7<5w{j2FP|a&6hcN88=JtN;6JA!{f8Loll){vN2z<5Z?&~A-EziW3nKJ z^W=5>K`i`78Ehvmw45BmQ9n76+mQt*YdvwH`Jx1cO$ICrIGFhB82ESb*MV&0pImpp W4Jfjof&VuDexS%^{>l5_I|2X__B%-c