Modern UI port, render-based @ui tests, dev seed, layer contracts

- Port modern clean theme (DM Sans, orange accent, app-* CSS classes)
  and screen redesigns from festipod-mockups; replace sketchy Ubuntu
  theme. New shared components: BottomNav, EventCover, EventMeetingPoints,
  Toast, AvatarStack, Tag, RelevanceIcon.

- Restructure from prototyping shell to real mobile web app:
  path-based routing (History API), Gallery/DemoMode/PhoneFrame removed,
  Storybook setup for screen/component browsing.

- ConnectScreen ported from mockup (QR-based user connection); routed
  at /profile/connect, wired from FriendsListScreen.

- Dev-only auto-seed of NG wallet when empty
  (gated on NODE_ENV !== 'production'); bootstrapWallet already
  self-checks for non-empty ngSet so safe even in race conditions.

- Render-based @ui test infrastructure: happy-dom + LocalDataProvider +
  RouterProvider via src/shared/test-harness/renderHelper.tsx, exposed
  on the world as renderedDoc. world.hasText/hasField/hasElement prefer
  the rendered DOM and fall back to source for backward compatibility.

- Migrate 25 brittle @ui assertions from regex-on-source to DOM
  queries; delete implementation-detail tests (showDuplicateWarning,
  importableEvents, importedFrom — anti-patterns per the new contract).
  Update feature files where the UI changed: "Mes amis" → "Mon réseau",
  "Mes événements à venir" → "À venir" on home, Thématique removed
  from create-event wizard, etc.

- Path-based @e2e steps (pushState + popstate dispatch) replacing the
  legacy "#/demo/…" hash routing tied to the deleted Gallery.

- Add .project/knowledge/test-layer-contracts.md defining the role of
  each test layer (@ui = display with seed data + DOM, @data = mutations
  through NG broker, @e2e = critical user journeys) with anti-patterns
  and migration consequences.

Test status: 75 passed / 71 skipped (explicit "non implémenté")
/ 2 failed (pre-existing @wip on ngSet.delete() NG ORM limitation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-05-18 09:49:50 +02:00
parent 7099c817db
commit 5a29938130
91 changed files with 5474 additions and 6362 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* Render helper for @ui tests.
*
* Spins up a happy-dom Window, renders a screen wrapped in the local data
* provider (seedData) + router, and exposes the resulting DOM for assertions.
*
* Why happy-dom + local provider:
* - happy-dom is in-process, fast, no broker/wallet needed
* - LocalDataProvider gives us the same seed data used in disconnected mode,
* so assertions can target real values ("Marie Dupont", "@mariedupont", …)
* - We bypass NextGraphProvider entirely — those tests aren't about NG
*/
import { Window } from 'happy-dom';
import React from 'react';
import { getScreen } from '../../screens/index';
import { LocalDataProvider } from '../context/FestipodDataContext';
import { RouterProvider } from '../../app/router';
let window: Window | null = null;
let root: any | null = null;
let createRoot: any = null;
/**
* Install happy-dom globals so React/ReactDOM can run. Must be called before
* ReactDOM is imported. Idempotent.
*/
export async function ensureDomGlobals(): Promise<void> {
if (window) return;
window = new Window({ url: 'http://localhost/' });
// Install minimal globals React/ReactDOM expect. Some (e.g. `navigator` on
// Node 22+) are already defined as getter-only properties — we use
// defineProperty to override them.
const setGlobal = (name: string, value: any) => {
try {
(globalThis as any)[name] = value;
} catch {
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
}
};
setGlobal('window', window);
setGlobal('document', window.document);
setGlobal('navigator', window.navigator);
setGlobal('HTMLElement', (window as any).HTMLElement);
setGlobal('HTMLInputElement', (window as any).HTMLInputElement);
setGlobal('HTMLTextAreaElement', (window as any).HTMLTextAreaElement);
setGlobal('HTMLButtonElement', (window as any).HTMLButtonElement);
setGlobal('Element', (window as any).Element);
setGlobal('Node', (window as any).Node);
setGlobal('Event', (window as any).Event);
setGlobal('MouseEvent', (window as any).MouseEvent);
setGlobal('PopStateEvent', (window as any).PopStateEvent);
setGlobal('requestAnimationFrame', (cb: any) => setTimeout(cb, 0));
setGlobal('cancelAnimationFrame', (id: any) => clearTimeout(id));
// Import ReactDOM only after globals are installed
const reactDom = await import('react-dom/client');
createRoot = reactDom.createRoot;
}
/**
* Render a screen at the given path. Returns the rendered document.
*
* If `path` is omitted, derives a default path from the screenId via the
* screen registry. Any previous render is unmounted first.
*/
export async function renderScreen(screenId: string, path?: string): Promise<Document> {
await ensureDomGlobals();
if (!window) throw new Error('DOM globals not installed');
// Unmount any previous render to keep tests isolated
if (root) {
root.unmount();
root = null;
}
const screen = getScreen(screenId);
if (!screen) throw new Error(`Unknown screen "${screenId}"`);
// Set pathname so RouterProvider picks the right route
const targetPath = path ?? defaultPathFor(screen.path);
(window.history as any).pushState({}, '', targetPath);
// Clear & mount
const doc = window.document as unknown as Document;
doc.body.innerHTML = '<div id="root"></div>';
const container = doc.getElementById('root')!;
root = createRoot(container);
await new Promise<void>((resolve) => {
root.render(
<LocalDataProvider>
<RouterProvider>
<screen.component />
</RouterProvider>
</LocalDataProvider>,
);
// Wait one microtask for React to flush effects
setTimeout(resolve, 0);
});
return doc;
}
/**
* Convert a registry path with `:id` placeholders to a concrete URL using the
* first seed event/user when applicable. Tests can override via the explicit
* `path` argument to renderScreen().
*/
function defaultPathFor(registryPath: string): string {
// Substitute :id with a seed id matching the route's resource. /users/:id
// needs a user id, /events/:id needs an event id. user-2 is the first
// non-current user in seedData (Jean Durand).
let path = registryPath;
if (path.startsWith('/users/')) {
path = path.replace(/:id/g, 'user-2');
} else {
path = path.replace(/:id/g, 'event-1');
}
return path.replace(/\/+$/, '') || '/';
}
export function unmountRender(): void {
if (root) {
root.unmount();
root = null;
}
}
export function getRenderedDocument(): Document | null {
return (window?.document as unknown as Document) ?? null;
}