Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
3 changed files with 80 additions and 37 deletions
Showing only changes of commit 767a18e98c - Show all commits
@@ -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
@@ -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);
});
+18 -1
View File
@@ -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