From 767a18e98c91c048827fe993d2d367ec5d3d8552 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Tue, 7 Jul 2026 14:23:25 +0200 Subject: [PATCH] test(@data): align us-7 inscription tests to Option B; fix authParticipationCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three us-7 count assertions encoded the OLD increment model (participantCount = baseline ± 1 via a fictional 'au départ N' step). Under Option B the count is owner-derived, not baseline±1 and not the joiner's to write, so those lines were false. Drop them; keep the real @data contract (join persists + participant + in list; leave is authoritative + gone). Move count convergence to a @data @wip scenario with an inline rationale (single-session can't derive the absolute count — shared-inbox accumulation + create-vs-read NURI-form; the @multibrowser reactive scenario is the real validation). Fix a harness bug: authParticipationCount enumerated protected docs via the all-accounts listEntityDocs (returned 0 for a fresh per-scenario virtual account — a false 0); use the bounded listMyEntityDocs(currentUser,'protected') (the same read-by-need path the app's idempotence check uses), and poll to absorb index lag. Full @data suite green: 18 scenarios / 89 steps. Désinscription contract untouched (caveat_participation-deletion). Build + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../us-7-inscription-evenement.feature | 40 ++++++++++--- .../event/steps/data/inscription.steps.ts | 58 ++++++++++--------- src/shared/test-harness/harness-ng.tsx | 19 +++++- 3 files changed, 80 insertions(+), 37 deletions(-) diff --git a/src/modules/event/features/us-7-inscription-evenement.feature b/src/modules/event/features/us-7-inscription-evenement.feature index 6ebc3cd..4956aa1 100644 --- a/src/modules/event/features/us-7-inscription-evenement.feature +++ b/src/modules/event/features/us-7-inscription-evenement.feature @@ -24,41 +24,65 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement Alors je peux voir la liste des événements # --- Data --- + # + # Option B (participantCount dérivé et possédé par le propriétaire) : le compteur + # n'est plus incrémenté par l'inscrit. L'inscrit écrit sa propre participation + # (protected) + dépose un marqueur dans l'inbox de l'événement ; la session du + # PROPRIÉTAIRE matérialise l'inbox et recalcule participantCount = 1 (hôte) + + # |inscriptions actives distinctes| sur SON propre doc, de façon RÉACTIVE et + # cross-session. Ce que le @data mono-session prouve ici : la participation + # elle-même (persistance, idempotence, désinscription AUTORITATIVE). La CONVERGENCE + # du compteur dérivé (1→2 sans reload) est validée là où elle a du sens — le + # scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît + # réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B). @data Scénario: S'inscrire à un événement Étant donné un événement "Formation CNV" existe Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" - Et l'événement "Formation CNV" a 8 participants au départ Quand l'utilisateur s'inscrit à l'événement "Formation CNV" Alors l'utilisateur est participant de l'événement "Formation CNV" Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV" - Et l'événement "Formation CNV" compte 9 participants @data Scénario: Se désinscrire d'un événement Étant donné un événement "Résidence Reconnexion" existe Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion" - Et l'événement "Résidence Reconnexion" a 12 participants au départ Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion" Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion" Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion" - Et l'événement "Résidence Reconnexion" compte 11 participants @data Scénario: L'inscription est idempotente Étant donné un événement "Résidence Reconnexion" existe Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion" - Et l'événement "Résidence Reconnexion" a 12 participants au départ Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion" Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion" - Et l'événement "Résidence Reconnexion" compte 12 participants @data Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit Étant donné un événement "Formation CNV" existe Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" - Et l'événement "Formation CNV" a 8 participants au départ Quand l'utilisateur se désinscrit de l'événement "Formation CNV" Alors l'utilisateur n'est plus participant de l'événement "Formation CNV" - Et l'événement "Formation CNV" compte 8 participants + + # --- Data : compteur dérivé (Option B) --- + # + # @wip : la CONVERGENCE de participantCount vers la valeur dérivée exige la + # matérialisation par la session du PROPRIÉTAIRE de l'événement. En @data + # mono-session, deux obstacles la rendent non-déterministe : (1) l'inbox de + # l'événement est ancrée à un compte-sentinelle partagé (les dépôts s'accumulent + # sur la vie du wallet de test, donc |actif| n'est pas borné au scénario) ; + # (2) l'événement seedé est CRÉÉ sous un NURI (non-versionné, celui de + # ownedEventIds) mais RELU sous un NURI versionné (:v:) — l'inscrit dépose sous + # le NURI relu, alors que le matérialiseur itère les NURI possédés non-versionnés, + # donc le compteur ne bouge jamais pour un événement seedé. La convergence 1→2 + # réactive est prouvée par le scénario @multibrowser réactif (propriétaire A + + # inscrit B, e2e-multibrowser.feature). Voir data-layer/knowledge_context-internals + # § participantCount et le brief brief_2026-07-06_reactive-reads-and-attendance §B. + @data @wip + Scénario: L'inscription fait converger le compteur dérivé du propriétaire + Étant donné un événement "Formation CNV" existe + Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" + Quand l'utilisateur s'inscrit à l'événement "Formation CNV" + Alors le compteur dérivé de l'événement "Formation CNV" reflète l'inscription diff --git a/src/modules/event/steps/data/inscription.steps.ts b/src/modules/event/steps/data/inscription.steps.ts index bde7af1..f129761 100644 --- a/src/modules/event/steps/data/inscription.steps.ts +++ b/src/modules/event/steps/data/inscription.steps.ts @@ -53,17 +53,6 @@ Given('l\'utilisateur est inscrit à l\'événement {string}', async function (t ); }); -Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) { - await this.appFrame!.evaluate( - async ([title, c]: [string, number]) => { - const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - if (event) await td.updateEvent(event['@id'], { participantCount: c }); - }, - [eventTitle, count] as [string, number], - ); -}); - // --- Actions --- When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { @@ -160,27 +149,28 @@ Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async expect(n, `broker must hold 0 participations to "${eventTitle}" after leave`).to.equal(0); }); -Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) { - // participantCount is persisted via SPARQL (durable); the reactive event re-read - // may lag the write, so poll until it reflects the expected value. +Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', async function (this: FestipodWorld, eventTitle: string) { + // OPTION B — the count is DERIVED and OWNER-materialized (see the feature's @wip + // rationale + data-layer/knowledge_context-internals §participantCount). In + // single-session @data it does NOT converge deterministically (shared inbox + // anchor accumulates deposits across the wallet's life; the seeded event is + // CREATED under an unversioned NURI but READ under a versioned one, so the + // joiner's deposit and the owner-materializer's owned-id never match). This step + // encodes the INTENT (the derived count reflects the join reactively) but the + // scenario is @wip — the real validation lives in the @multibrowser reactive + // scenario (owner A + joiner B, e2e-multibrowser.feature). Waits reactively on + // the owner-materialized count moving above the host baseline. await this.appFrame!.waitForFunction( - ([title, expected]: [string, number]) => { - const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - return !!event && event.participantCount === expected; - }, - [eventTitle, expectedCount] as [string, number], - { timeout: 20000 }, - ).catch(() => { /* surface the actual value in the assertion below */ }); - const count = await this.appFrame!.evaluate( (title) => { const td = (window as any).__testData; - const event = [...td.events].find((e: any) => e.title === title); - return event?.participantCount ?? -1; + const ev = [...td.events].find((e: any) => e.title === title); + if (!ev) return false; + const rs = td.reactiveEventState(ev['@id']); + return rs.found && rs.participantCount > 1; // host(1) + at least this join }, eventTitle, + { timeout: 20000 }, ); - expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount); }); Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { @@ -221,12 +211,24 @@ Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'év Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) { // Idempotence at the DATA level: exactly ONE participation on the broker for // (event, user), no matter how many times the join was attempted. Assert the - // AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes). + // AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes). POLL the + // authoritative count: the participation is written into its own protected doc + // and its index-append propagates async, so a single unpolled read can catch 0 + // before the write is queryable (observed flake) — poll until the durable state + // (exactly 1) is visible, which also proves the second join did NOT add a dupe. const n = await this.appFrame!.evaluate(async (title) => { const td = (window as any).__testData; const event = [...td.events].find((e: any) => e.title === title); if (!event) return -1; - return td.authParticipationCount(event['@id'], await td.ensureCurrentUser()); + const uid = await td.ensureCurrentUser(); + let last = 0; + for (let i = 0; i < 20; i++) { + last = await td.authParticipationCount(event['@id'], uid); + if (last === 1) return 1; // exactly one — idempotent, stop early + if (last > 1) return last; // a dupe leaked — fail fast with the real count + await new Promise(r => setTimeout(r, 1000)); + } + return last; }, eventTitle); expect(n, 'User should have exactly one participation record on the broker').to.equal(1); }); diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 75ace22..60864b0 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -309,7 +309,24 @@ function ConnectedHarness() { // store-root graph. This stays authoritative (bypasses the reactive set): // it counts the (event,user) triples actually persisted in the broker. const reg = await import('../utils/storeRegistry'); - const protectedDocs = await reg.listEntityDocs('protected'); + // Enumerate the CURRENT account's own protected docs — the read-by-need + // path the APP uses (registration.countUserParticipations → + // listMyEntityDocs), NOT the all-accounts `listEntityDocs` fan-out. Each + // @data scenario runs under a FRESH virtual account (freshScenarioUsername + // in localStorage), whose participation docs live ONLY in that account's + // protected scope index. The all-accounts fan-out (`allAccounts()`) does + // not surface the fresh account here (its registry record isn't in the + // enumerated set), so `listEntityDocs('protected')` returned 0 docs and the + // authoritative count was a false 0 for a participation that provably + // exists. `listMyEntityDocs(currentUser, 'protected')` reads exactly the + // current account's own docs — the same bounded path the app writes/reads + // and the sanctioned non-hanging enumeration. Falls back to the fan-out + // only when no login is present (dev/demo). + let currentUser = ''; + try { currentUser = window.localStorage.getItem('festipod.account.username') || ''; } catch { /* opaque origin */ } + const protectedDocs = currentUser + ? await reg.listMyEntityDocs(currentUser, 'protected') + : await reg.listEntityDocs('protected'); let total = 0; for (const g of protectedDocs) { // Anchored default-graph (no `GRAPH` clause): participations are