Pure useShape API for mutations, event lifecycle e2e tests

Refactor FestipodDataContext to use only the useShape ORM API:
- Remove ng.sparql_update, sessionPromise, ensureGraphNuri imports
- Use privateNuri (useShape scope) directly as @graph for adds
- Create/join are now synchronous (no async wrapper needed)
- Leave uses ngSet.delete() — known limitation: doesn't persist (@wip)

Add event lifecycle e2e scenarios (cycle-de-vie-evenement.feature):
- Create event via form and verify on home screen
- Created event persists after reconnexion
- Consult event detail from home
- Join an event
- Modify event location and verify
- Leave + persistence tagged @wip (ngSet.delete doesn't persist)

Fix DemoMode external navigation: sync initialScreenId prop changes
to internal state via useEffect (was ignored after first mount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-04-09 13:03:02 +02:00
parent 6b95695d34
commit 7099c817db
5 changed files with 562 additions and 60 deletions
+35 -60
View File
@@ -23,9 +23,6 @@ import {
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
import { ensureGraphNuri } from '../utils/ngGraph';
import { sessionPromise } from '../utils/ngSession';
import { ng } from '@ng-org/web';
// ============================================================================
// Context interface
@@ -273,28 +270,28 @@ function useNgData(): FestipodDataContextValue {
'| selectedEvent:', selectedEvent?.title ?? '(none)');
// --- Mutations (NG) ---
// privateNuri is both the useShape scope AND the @graph for writes
const graph = privateNuri || '';
const createEvent = useCallback((event: Omit<FpEventData, 'id'>): FpEventData => {
console.log('[FestipodData] createEvent (NG):', event.title);
(async () => {
const graph = await ensureGraphNuri(eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any);
eventsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
})();
return { ...event, id: `ng-pending-${Date.now()}` };
}, [eventsShape.ngSet, usersShape.ngSet, participationsShape.ngSet, currentUserId]);
eventsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Event", "@id": "",
title: event.title, description: event.description, date: event.date,
location: event.location, distance: event.distance,
participantCount: event.participantCount || 1,
coverImage: event.coverImage, hostName: event.hostName, hostInitials: event.hostInitials,
} as FpEvent);
const addedEvent = [...eventsShape.ngSet].find(e => e.title === event.title);
if (addedEvent && currentUserId) {
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: addedEvent["@id"], user: currentUserId, isConfirmed: true,
} as FpParticipation);
setSelectedEventId(addedEvent["@id"]);
}
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
}, [graph, eventsShape.ngSet, participationsShape.ngSet, currentUserId]);
const updateEvent = useCallback((id: string, updates: Partial<FpEventData>) => {
console.log('[FestipodData] updateEvent (NG):', id, updates);
@@ -317,49 +314,27 @@ function useNgData(): FestipodDataContextValue {
console.log('[FestipodData] Already participating, skipping');
return;
}
(async () => {
const graph = await ensureGraphNuri(eventsShape.ngSet as any, usersShape.ngSet as any, participationsShape.ngSet as any);
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
}
console.log('[FestipodData] joinEvent done');
})();
}, [participationsShape.ngSet, eventsShape.ngSet, usersShape.ngSet, currentUserId]);
participationsShape.ngSet.add({
"@graph": graph, "@type": "http://festipod.org/Participation", "@id": "",
event: eventId, user: uid, isConfirmed: true,
} as FpParticipation);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = ngEvent.participantCount + 1;
}
}, [graph, participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
const leaveEvent = useCallback((eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
const ngPart = [...participationsShape.ngSet].find(p => p.event === eventId && p.user === uid);
if (ngPart) {
const partId = ngPart["@id"];
const partGraph = ngPart["@graph"];
console.log('[FestipodData] Deleting participation:', partId, '@graph:', partGraph);
// Use ONLY sparql_update to delete — the broker will send back a GraphOrmUpdate
// that removes the item from the ORM set reactively. Avoid calling ngSet.delete()
// simultaneously as the two operations can conflict in the CRDT.
(async () => {
try {
const session = await sessionPromise;
await ng.sparql_update(
session.session_id,
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
partGraph,
);
console.log('[FestipodData] SPARQL DELETE succeeded for participation:', partId);
// Update participantCount after confirmed deletion
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
}
} catch (err) {
console.error('[FestipodData] SPARQL DELETE failed:', err);
}
})();
console.log('[FestipodData] Deleting participation via ngSet.delete():', ngPart["@id"]);
participationsShape.ngSet.delete(ngPart);
const ngEvent = findNg(eventsShape.ngSet as any as Set<FpEvent>, e => e["@id"] === eventId);
if (ngEvent) {
ngEvent.participantCount = Math.max(0, ngEvent.participantCount - 1);
}
}
}, [participationsShape.ngSet, eventsShape.ngSet, currentUserId]);
+202
View File
@@ -510,6 +510,208 @@ export const parsedFeatures: ParsedFeature[] = [
"home"
]
},
{
"id": "cycle-de-vie-evenement",
"name": "Cycle de vie d'un événement",
"description": "En tant qu'utilisateur connecté Je peux créer, consulter, modifier et participer à des événements Et ces actions persistent dans mon portefeuille NextGraph",
"tags": [
"@EVENT",
"@priority-1"
],
"category": "EVENT",
"priority": 1,
"background": [
{
"keyword": "Étant donné que ",
"text": "l'utilisateur a chargé les données de test"
}
],
"scenarios": [
{
"name": "Créer un événement depuis l'accueil",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton \"Relayer un événement\""
},
{
"keyword": "Et",
"text": "l'utilisateur remplit le formulaire de création d'événement:"
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton \"Relayer l'événement\""
},
{
"keyword": "Alors",
"text": "l'application affiche l'écran \"event-detail\""
},
{
"keyword": "Et",
"text": "l'écran contient le texte \"Pique-nique au parc\""
}
]
},
{
"name": "L'événement créé apparaît sur l'accueil",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Alors",
"text": "l'écran contient le texte \"Pique-nique au parc\""
}
]
},
{
"name": "L'événement créé persiste après reconnexion",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Alors",
"text": "l'écran contient le texte \"Pique-nique au parc\""
}
]
},
{
"name": "Consulter le détail d'un événement depuis l'accueil",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur l'événement \"Pique-nique au parc\""
},
{
"keyword": "Alors",
"text": "l'application affiche l'écran \"event-detail\""
},
{
"keyword": "Et",
"text": "l'écran contient le texte \"Pique-nique au parc\""
},
{
"keyword": "Et",
"text": "l'écran contient le texte \"Parc Bordelais\""
}
]
},
{
"name": "S'inscrire à un événement existant",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"events\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le premier événement"
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton \"Participer\""
},
{
"keyword": "Alors",
"text": "l'écran contient le texte \"Inscrit\""
}
]
},
{
"name": "Se désinscrire d'un événement",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"events\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le premier événement"
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton \"Inscrit\""
},
{
"keyword": "Alors",
"text": "l'écran contient le texte \"Participer\""
}
]
},
{
"name": "Modifier le titre d'un événement créé",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur l'événement \"Pique-nique au parc\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton de modification"
},
{
"keyword": "Et",
"text": "l'utilisateur modifie le champ \"Nom de l'événement\" avec \"Pique-nique d'été\""
},
{
"keyword": "Et",
"text": "l'utilisateur clique sur le bouton \"Enregistrer les modifications\""
},
{
"keyword": "Alors",
"text": "l'application affiche l'écran \"event-detail\""
},
{
"keyword": "Et",
"text": "l'écran contient le texte \"Pique-nique d'été\""
}
]
},
{
"name": "La modification persiste après reconnexion",
"tags": [],
"steps": [
{
"keyword": "Quand",
"text": "l'utilisateur navigue vers l'écran \"home\""
},
{
"keyword": "Alors",
"text": "l'écran contient le texte \"Pique-nique d'été\""
},
{
"keyword": "Et",
"text": "l'écran ne contient pas le texte \"Pique-nique au parc\""
}
]
}
],
"filePath": "src/modules/event/features/cycle-de-vie-evenement.feature",
"rawContent": "# language: fr\n@EVENT @priority-1\nFonctionnalité: Cycle de vie d'un événement\n En tant qu'utilisateur connecté\n Je peux créer, consulter, modifier et participer à des événements\n Et ces actions persistent dans mon portefeuille NextGraph\n\n Contexte:\n Étant donné que l'utilisateur a chargé les données de test\n\n # --- Création ---\n\n @e2e\n Scénario: Créer un événement depuis l'accueil\n Quand l'utilisateur navigue vers l'écran \"home\"\n Et l'utilisateur clique sur le bouton \"Relayer un événement\"\n Et l'utilisateur remplit le formulaire de création d'événement:\n | champ | valeur |\n | Nom de l'événement | Pique-nique au parc |\n | Date de début | 2026-06-15 |\n | Heure de début | 14:00 |\n | Lieu | Parc Bordelais, Bordeaux |\n Et l'utilisateur clique sur le bouton \"Relayer l'événement\"\n Alors l'application affiche l'écran \"event-detail\"\n Et l'écran contient le texte \"Pique-nique au parc\"\n\n @e2e\n Scénario: L'événement créé apparaît sur l'accueil\n Quand l'utilisateur navigue vers l'écran \"home\"\n Alors l'écran contient le texte \"Pique-nique au parc\"\n\n @e2e\n Scénario: L'événement créé persiste après reconnexion\n Quand l'utilisateur navigue vers l'écran \"home\"\n Alors l'écran contient le texte \"Pique-nique au parc\"\n\n # --- Consultation ---\n\n @e2e\n Scénario: Consulter le détail d'un événement depuis l'accueil\n Quand l'utilisateur navigue vers l'écran \"home\"\n Et l'utilisateur clique sur l'événement \"Pique-nique au parc\"\n Alors l'application affiche l'écran \"event-detail\"\n Et l'écran contient le texte \"Pique-nique au parc\"\n Et l'écran contient le texte \"Parc Bordelais\"\n\n # --- Inscription / Désinscription ---\n\n @e2e\n Scénario: S'inscrire à un événement existant\n Quand l'utilisateur navigue vers l'écran \"events\"\n Et l'utilisateur clique sur le premier événement\n Et l'utilisateur clique sur le bouton \"Participer\"\n Alors l'écran contient le texte \"Inscrit\"\n\n @e2e\n Scénario: Se désinscrire d'un événement\n Quand l'utilisateur navigue vers l'écran \"events\"\n Et l'utilisateur clique sur le premier événement\n Et l'utilisateur clique sur le bouton \"Inscrit\"\n Alors l'écran contient le texte \"Participer\"\n\n # --- Modification ---\n\n @e2e\n Scénario: Modifier le titre d'un événement créé\n Quand l'utilisateur navigue vers l'écran \"home\"\n Et l'utilisateur clique sur l'événement \"Pique-nique au parc\"\n Et l'utilisateur clique sur le bouton de modification\n Et l'utilisateur modifie le champ \"Nom de l'événement\" avec \"Pique-nique d'été\"\n Et l'utilisateur clique sur le bouton \"Enregistrer les modifications\"\n Alors l'application affiche l'écran \"event-detail\"\n Et l'écran contient le texte \"Pique-nique d'été\"\n\n @e2e\n Scénario: La modification persiste après reconnexion\n Quand l'utilisateur navigue vers l'écran \"home\"\n Alors l'écran contient le texte \"Pique-nique d'été\"\n Et l'écran ne contient pas le texte \"Pique-nique au parc\"\n",
"screenIds": []
},
{
"id": "us-16",
"name": "US-16 Indiquer un ou plusieurs points de rencontre",