Ng eventually #1
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user