discovery-media-player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ escapeHtml,
4
+ initials,
5
+ avatarHtml,
6
+ formatMessageBody,
7
+ flattenPresence,
8
+ attendeeKey,
9
+ authorToken,
10
+ reactorId,
11
+ isMine,
12
+ canModerate,
13
+ isMentioned,
14
+ shouldNotify,
15
+ unreadLabel,
16
+ STORAGE_KEYS,
17
+ type KeyValueStore,
18
+ } from "../live";
19
+
20
+ function memoryStore(seed: Record<string, string> = {}): KeyValueStore & { data: Record<string, string> } {
21
+ const data = { ...seed };
22
+ return { data, getItem: (k) => (k in data ? data[k] : null), setItem: (k, v) => { data[k] = v; } };
23
+ }
24
+
25
+ const hostileStore: KeyValueStore = {
26
+ getItem() { throw new Error("navigation privée"); },
27
+ setItem() { throw new Error("navigation privée"); },
28
+ };
29
+
30
+ describe("live — présence dédoublonnée", () => {
31
+ // LE bug « je me vois deux fois » : la clé du canal contient un identifiant tiré au sort à chaque
32
+ // chargement, donc un reconnecté occupe deux entrées. Seule l'identité stable les fond.
33
+ it("fond deux entrées du même participant reconnecté", () => {
34
+ const list = flattenPresence({
35
+ "moi@example.fr:aleatoire1": [{ uid: "moi@example.fr", name: "Moi" }],
36
+ "moi@example.fr:aleatoire2": [{ uid: "moi@example.fr", name: "Moi" }],
37
+ "autre:xyz": [{ uid: "autre@example.fr", name: "Autre" }],
38
+ });
39
+ expect(list).toHaveLength(2);
40
+ expect(list.map((p) => p.name)).toEqual(["Moi", "Autre"]);
41
+ });
42
+
43
+ it("l'identité est insensible à la casse et retombe sur l'email puis le nom", () => {
44
+ expect(flattenPresence({ a: [{ email: "A@Example.fr" }], b: [{ email: "a@example.fr" }] })).toHaveLength(1);
45
+ expect(flattenPresence({ a: [{ name: "Léa" }], b: [{ name: "léa" }] })).toHaveLength(1);
46
+ });
47
+
48
+ // En cas de collision, c'est la métadonnée du présentateur qui porte le rôle : la perdre
49
+ // ferait disparaître le badge « présentateur » de la liste des participants.
50
+ it("le présentateur l'emporte sur son propre doublon spectateur", () => {
51
+ const list = flattenPresence({
52
+ onglet1: [{ uid: "u1", name: "Léa", role: "viewer" }],
53
+ onglet2: [{ uid: "u1", name: "Léa", role: "presenter" }],
54
+ });
55
+ expect(list).toHaveLength(1);
56
+ expect(list[0].role).toBe("presenter");
57
+ });
58
+
59
+ // Sans identité, on ne peut PAS dédoublonner : fondre des inconnus distincts en un seul
60
+ // masquerait des participants réels.
61
+ it("garde distincts les participants sans aucune identité", () => {
62
+ expect(flattenPresence({ a: [{}], b: [{}] })).toHaveLength(2);
63
+ });
64
+
65
+ it("supporte un état vide, nul ou biscornu", () => {
66
+ expect(flattenPresence(null)).toEqual([]);
67
+ expect(flattenPresence(undefined)).toEqual([]);
68
+ expect(flattenPresence({})).toEqual([]);
69
+ expect(flattenPresence({ a: [] })).toEqual([]);
70
+ });
71
+ });
72
+
73
+ describe("live — échappement et mise en forme", () => {
74
+ it("échappe ce qui pourrait sortir du balisage", () => {
75
+ expect(escapeHtml('<b>&"')).toBe("&lt;b&gt;&amp;&quot;");
76
+ expect(escapeHtml(null)).toBe("");
77
+ expect(escapeHtml(undefined)).toBe("");
78
+ });
79
+
80
+ // Un message contenant </script> a déjà produit un XSS stocké sur la page audience.
81
+ it("neutralise une tentative de fermeture de balise", () => {
82
+ const out = formatMessageBody("</script><script>alert(1)</script>");
83
+ expect(out).not.toMatch(/<\/script/i);
84
+ expect(out).not.toMatch(/<script/i);
85
+ });
86
+
87
+ it("neutralise un gestionnaire d'événement injecté", () => {
88
+ const out = formatMessageBody('<img src=x onerror="alert(1)">');
89
+ expect(out).not.toMatch(/<img/i);
90
+ expect(out).toContain("&lt;img");
91
+ });
92
+
93
+ // L'ordre est vital : enrichir AVANT d'échapper rendrait le balisage de l'auteur exécutable.
94
+ it("échappe d'abord, enrichit ensuite", () => {
95
+ const out = formatMessageBody('Voir <b>ici</b> https://exemple.fr/a?x=1&y=2');
96
+ expect(out).toContain("&lt;b&gt;ici&lt;/b&gt;");
97
+ expect(out).toContain('<a href="https://exemple.fr/a?x=1&amp;y=2"');
98
+ expect(out).toContain("rel=noreferrer");
99
+ });
100
+
101
+ it("ne transforme en lien QUE http(s) — jamais javascript: ni data:", () => {
102
+ for (const hostile of ["javascript:alert(1)", "data:text/html,<b>x</b>", "vbscript:x"]) {
103
+ expect(formatMessageBody(hostile)).not.toContain("<a href");
104
+ }
105
+ });
106
+
107
+ it("met en valeur les mentions, accents compris", () => {
108
+ expect(formatMessageBody("salut @léa ça va")).toContain("<span class=cm-mention>@léa</span>");
109
+ expect(formatMessageBody("email@exemple.fr")).not.toContain("cm-mention"); // pas en début de mot
110
+ });
111
+
112
+ it("initiales et avatar de repli", () => {
113
+ expect(initials("Léa Martin")).toBe("LM");
114
+ expect(initials("Solo")).toBe("S");
115
+ expect(initials("")).toBe("?");
116
+ expect(initials(undefined)).toBe("?");
117
+ expect(avatarHtml("", "Léa Martin")).toBe("LM");
118
+ expect(avatarHtml('x" onerror="alert(1)', "n")).toBe('<img src="x&quot; onerror=&quot;alert(1)" alt="">');
119
+ });
120
+ });
121
+
122
+ describe("live — identité persistante", () => {
123
+ it("un membre est identifié par son email, en minuscules", () => {
124
+ expect(attendeeKey({ email: "Lea@Example.FR" }, memoryStore())).toBe("lea@example.fr");
125
+ });
126
+
127
+ it("un anonyme reçoit une clé persistante, réutilisée ensuite", () => {
128
+ const store = memoryStore();
129
+ const first = attendeeKey(null, store, "session", () => "abc123");
130
+ expect(first).toBe("anon-abc123");
131
+ expect(store.data[STORAGE_KEYS.attendeeKey]).toBe("anon-abc123");
132
+ expect(attendeeKey(null, store, "session", () => "autre")).toBe("anon-abc123");
133
+ });
134
+
135
+ // Sans persistance, le participant comptera pour deux visites : c'est dégradé, pas cassé.
136
+ it("retombe sur l'identifiant de session si le stockage est indisponible", () => {
137
+ expect(attendeeKey(null, hostileStore, "session-42")).toBe("anon-session-42");
138
+ expect(attendeeKey(null, null, "session-42")).toBe("anon-session-42");
139
+ });
140
+
141
+ it("le jeton d'auteur est créé une fois puis réutilisé", () => {
142
+ const store = memoryStore();
143
+ const token = authorToken(store, () => "jeton-stable");
144
+ expect(token).toBe("jeton-stable");
145
+ expect(authorToken(store, () => "autre")).toBe("jeton-stable");
146
+ });
147
+
148
+ it("le jeton d'auteur survit à un stockage qui lève", () => {
149
+ expect(authorToken(hostileStore, () => "ephemere")).toBe("ephemere");
150
+ });
151
+
152
+ it("l'identifiant de réaction retombe sur le nom pour un anonyme", () => {
153
+ expect(reactorId({ email: "A@B.fr" })).toBe("a@b.fr");
154
+ expect(reactorId({ name: "Léa" })).toBe("léa");
155
+ expect(reactorId(null)).toBe("");
156
+ });
157
+ });
158
+
159
+ describe("live — prédicats", () => {
160
+ it("reconnaît mes messages par email, et par nom entre anonymes", () => {
161
+ expect(isMine({ author_email: "a@b.fr" }, { email: "a@b.fr" })).toBe(true);
162
+ expect(isMine({ author_email: "a@b.fr" }, { email: "z@b.fr" })).toBe(false);
163
+ expect(isMine({ author_name: "Léa" }, { name: "Léa" })).toBe(true);
164
+ expect(isMine({ author_name: "Léa" }, { name: "Autre" })).toBe(false);
165
+ });
166
+
167
+ // Un membre identifié ne doit pas s'approprier le message d'un anonyme homonyme.
168
+ it("ne mélange pas un auteur identifié et un lecteur anonyme", () => {
169
+ expect(isMine({ author_email: "a@b.fr", author_name: "Léa" }, { name: "Léa" })).toBe(false);
170
+ expect(isMine({ author_name: "Léa" }, { email: "a@b.fr", name: "Léa" })).toBe(false);
171
+ });
172
+
173
+ it("seul le présentateur modère", () => {
174
+ expect(canModerate({ role: "presenter" })).toBe(true);
175
+ expect(canModerate({ role: "viewer" })).toBe(false);
176
+ expect(canModerate(null)).toBe(false);
177
+ });
178
+
179
+ it("détecte une citation par le prénom, sauf la sienne", () => {
180
+ const me = { name: "Léa Martin", email: "lea@b.fr" };
181
+ expect(isMentioned({ body: "merci @léa" }, me)).toBe(true);
182
+ expect(isMentioned({ body: "MERCI @LÉA" }, me)).toBe(true);
183
+ expect(isMentioned({ body: "merci @thomas" }, me)).toBe(false);
184
+ expect(isMentioned({ body: "@léa je me cite", author_email: "lea@b.fr" }, me)).toBe(false);
185
+ expect(isMentioned({ body: "@léa" }, null)).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("live — badge de non-lus", () => {
190
+ const me = { email: "moi@b.fr", name: "Moi" };
191
+ const base = { me, historyLoaded: true, chatHidden: true };
192
+
193
+ it("compte un message reçu quand le panneau est fermé", () => {
194
+ expect(shouldNotify({ ...base, msg: { author_email: "autre@b.fr", body: "coucou" } })).toBe(true);
195
+ });
196
+
197
+ // Sans cette garde, l'historique rechargé au join afficherait un badge de dizaines de messages vus.
198
+ it("ne compte jamais l'historique rejoué", () => {
199
+ expect(shouldNotify({ ...base, historyLoaded: false, msg: { author_email: "autre@b.fr" } })).toBe(false);
200
+ });
201
+
202
+ it("ne compte ni mes messages, ni les supprimés, ni panneau ouvert", () => {
203
+ expect(shouldNotify({ ...base, msg: { author_email: "moi@b.fr" } })).toBe(false);
204
+ expect(shouldNotify({ ...base, msg: { author_email: "autre@b.fr", deleted: true } })).toBe(false);
205
+ expect(shouldNotify({ ...base, chatHidden: false, msg: { author_email: "autre@b.fr" } })).toBe(false);
206
+ });
207
+
208
+ it("plafonne le libellé à 9+", () => {
209
+ expect([0, 1, 9, 10, 250].map(unreadLabel)).toEqual(["", "1", "9", "9+", "9+"]);
210
+ });
211
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ sanitizeContent,
4
+ cycleMapType,
5
+ mapTypeLabel,
6
+ initialMapContent,
7
+ MAP_TYPES,
8
+ DEFAULT_CENTER,
9
+ type MapContent,
10
+ } from "../presentation-content";
11
+
12
+ /** Le retour est une union : ce raccourci évite de répéter l'affinage dans chaque assertion. */
13
+ const asMap = (input: unknown) => sanitizeContent(input) as MapContent | null;
14
+
15
+ // Ce contrat traverse trois frontières : le navigateur du présentateur le produit, le serveur le
16
+ // valide, le navigateur de chaque spectateur l'applique. Il n'avait AUCUN test alors qu'une
17
+ // divergence s'y traduit par une audience qui ne voit pas ce que montre le présentateur — sans
18
+ // erreur nulle part.
19
+
20
+ describe("contenu de présentation — retour au document", () => {
21
+ // `null` = « on présente le document ». C'est le repli sûr : jamais un écran vide.
22
+ it("tout ce qui n'est pas reconnu ramène au document", () => {
23
+ for (const input of [null, undefined, "", 0, "map", [], { kind: "pdf" }, {}, { kind: "evil" }, { kind: 42 }]) {
24
+ expect(sanitizeContent(input), JSON.stringify(input)).toBeNull();
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("contenu de présentation — carte", () => {
30
+ it("conserve une carte valide", () => {
31
+ expect(sanitizeContent({ kind: "map", center: [48.85, 2.35], zoom: 14, marker: [48.86, 2.34], mapType: "satellite", label: "Paris" }))
32
+ .toEqual({ kind: "map", center: [48.85, 2.35], zoom: 14, marker: [48.86, 2.34], mapType: "satellite", label: "Paris" });
33
+ });
34
+
35
+ it("retombe sur la France entière quand le centre est inexploitable", () => {
36
+ for (const center of [undefined, "48,2", ["a", "b"], [1]]) {
37
+ expect(asMap({ kind: "map", center })?.center, JSON.stringify(center)).toEqual(DEFAULT_CENTER);
38
+ }
39
+ });
40
+
41
+ it("borne le zoom entre 1 et 21, et le tronque", () => {
42
+ expect(asMap({ kind: "map", zoom: 99 })?.zoom).toBe(21);
43
+ expect(asMap({ kind: "map", zoom: -5 })?.zoom).toBe(1);
44
+ expect(asMap({ kind: "map", zoom: 12.7 })?.zoom).toBe(12);
45
+ expect(asMap({ kind: "map", zoom: "14" })?.zoom).toBe(14);
46
+ });
47
+
48
+ it("n'accepte qu'un fond de carte connu", () => {
49
+ for (const t of MAP_TYPES) expect(asMap({ kind: "map", mapType: t })?.mapType).toBe(t);
50
+ for (const t of ["terrain", "", null, "<script>"]) expect(asMap({ kind: "map", mapType: t })?.mapType).toBeNull();
51
+ });
52
+
53
+ // Le libellé est saisi par le présentateur et s'affiche chez tous : il est borné ici, échappé à l'affichage.
54
+ it("borne le libellé à 160 caractères", () => {
55
+ expect(asMap({ kind: "map", label: "x".repeat(500) })?.label).toHaveLength(160);
56
+ expect(asMap({ kind: "map", label: "" })?.label).toBeNull();
57
+ });
58
+
59
+ it("ne garde un marqueur que s'il est exploitable", () => {
60
+ expect(asMap({ kind: "map", marker: [1, 2] })?.marker).toEqual([1, 2]);
61
+ for (const m of [undefined, null, "1,2", ["a", 2]]) {
62
+ expect(asMap({ kind: "map", marker: m })?.marker, JSON.stringify(m)).toBeNull();
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("contenu de présentation — Street View", () => {
68
+ it("conserve une vue valide", () => {
69
+ expect(sanitizeContent({ kind: "streetview", position: [48.85, 2.35], pov: { heading: 90, pitch: 10 }, zoom: 2 }))
70
+ .toEqual({ kind: "streetview", position: [48.85, 2.35], pov: { heading: 90, pitch: 10 }, zoom: 2 });
71
+ });
72
+
73
+ // Une vue Street View sans position n'a aucun sens : on ramène au document.
74
+ it("refuse une vue sans position exploitable", () => {
75
+ for (const position of [undefined, null, "48,2", [1], ["a", "b"]]) {
76
+ expect(sanitizeContent({ kind: "streetview", position }), JSON.stringify(position)).toBeNull();
77
+ }
78
+ });
79
+
80
+ // Au-delà de ±90° l'orientation verticale se retourne : la borne évite une audience la tête à l'envers.
81
+ it("borne l'inclinaison à ±90° et le zoom à 0–5", () => {
82
+ expect(sanitizeContent({ kind: "streetview", position: [1, 2], pov: { pitch: 200 } })).toMatchObject({ pov: { pitch: 90 } });
83
+ expect(sanitizeContent({ kind: "streetview", position: [1, 2], pov: { pitch: -200 } })).toMatchObject({ pov: { pitch: -90 } });
84
+ expect(sanitizeContent({ kind: "streetview", position: [1, 2], zoom: 50 })?.zoom).toBe(5);
85
+ });
86
+
87
+ it("se passe d'une orientation absente ou biscornue", () => {
88
+ expect(sanitizeContent({ kind: "streetview", position: [1, 2] })).toMatchObject({ pov: { heading: 0, pitch: 0 } });
89
+ expect(sanitizeContent({ kind: "streetview", position: [1, 2], pov: "nord" })).toMatchObject({ pov: { heading: 0, pitch: 0 } });
90
+ });
91
+ });
92
+
93
+ describe("contenu de présentation — bouton de fond de carte", () => {
94
+ it("tourne dans l'ordre, et repart de zéro depuis l'inconnu", () => {
95
+ expect(cycleMapType("roadmap")).toBe("satellite");
96
+ expect(cycleMapType("satellite")).toBe("hybrid");
97
+ expect(cycleMapType("hybrid")).toBe("roadmap");
98
+ expect(cycleMapType(null)).toBe("roadmap");
99
+ expect(cycleMapType("terrain")).toBe("roadmap");
100
+ });
101
+
102
+ // Le bouton annonce ce vers quoi on va, pas ce qu'on regarde.
103
+ it("annonce la destination, pas l'état courant", () => {
104
+ expect(mapTypeLabel("roadmap")).toContain("Satellite");
105
+ expect(mapTypeLabel("satellite")).toContain("Hybride");
106
+ expect(mapTypeLabel("hybrid")).toContain("Plan");
107
+ expect(mapTypeLabel(null)).toContain("Plan");
108
+ });
109
+ });
110
+
111
+ describe("contenu de présentation — départ", () => {
112
+ it("démarre sur la France entière, et survit à son propre aller-retour", () => {
113
+ const initial = initialMapContent();
114
+ expect(initial).toMatchObject({ kind: "map", center: DEFAULT_CENTER, zoom: 6, marker: null });
115
+ expect(sanitizeContent(initial)).toEqual(initial);
116
+ });
117
+
118
+ // Le contenu revient du serveur puis repart : il doit être stable, sinon la carte dériverait
119
+ // à chaque aller-retour.
120
+ it("est idempotent", () => {
121
+ const once = sanitizeContent({ kind: "map", center: [48.85, 2.35], zoom: 14, mapType: "hybrid", label: "Paris" });
122
+ expect(sanitizeContent(once)).toEqual(once);
123
+ const sv = sanitizeContent({ kind: "streetview", position: [48.85, 2.35], pov: { heading: 12, pitch: 3 }, zoom: 2 });
124
+ expect(sanitizeContent(sv)).toEqual(sv);
125
+ });
126
+
127
+ it("ne partage pas son tableau de centre entre deux appels", () => {
128
+ const a = initialMapContent();
129
+ a.center[0] = 0;
130
+ expect(initialMapContent().center).toEqual(DEFAULT_CENTER);
131
+ });
132
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { presentationTransition, switchDocUrl } from "../presentation-state";
3
+
4
+ const genres = (row: unknown, view = {}) =>
5
+ presentationTransition(row as never, view).map((a) => a.kind);
6
+
7
+ describe("suivi de l'audience — l'ordre est une règle", () => {
8
+ // Une présentation terminée l'emporte sur tout : le reste de l'état n'a plus de sens.
9
+ it("une présentation terminée coupe court", () => {
10
+ expect(genres({ active: false, current_page: 4, content: { kind: "map" }, file_url: "https://x/y" }))
11
+ .toEqual(["ended"]);
12
+ });
13
+
14
+ // Le présentateur montre autre chose : l'audience ne doit ni revenir au PDF ni suivre ses pages.
15
+ it("une carte SUSPEND le suivi du document", () => {
16
+ expect(genres({ active: true, content: { kind: "map", center: [48.8, 2.3], zoom: 14 }, current_page: 7 }))
17
+ .toEqual(["show-map"]);
18
+ expect(genres({ active: true, content: { kind: "streetview", position: [48.8, 2.3] }, current_page: 7 }))
19
+ .toEqual(["show-map"]);
20
+ });
21
+
22
+ it("revenir au document quitte la carte", () => {
23
+ expect(genres({ active: true, current_page: 3 })).toEqual(["leave-map", "show-page"]);
24
+ expect(genres({ active: true, content: null, current_page: 3 })).toEqual(["leave-map", "show-page"]);
25
+ });
26
+
27
+ // La page 3 de l'ancien document n'est pas la page 3 du nouveau.
28
+ it("un changement de document précède — et remplace — un changement de page", () => {
29
+ const actions = presentationTransition(
30
+ { active: true, file_url: "https://x/neuf.pdf", current_page: 9 },
31
+ { docUrl: "https://x/ancien.pdf" },
32
+ );
33
+ expect(actions.map((a) => a.kind)).toEqual(["leave-map", "switch-doc"]);
34
+ expect(actions[1]).toMatchObject({ kind: "switch-doc", url: "https://x/neuf.pdf" });
35
+ });
36
+
37
+ it("le même document ne déclenche pas de rechargement", () => {
38
+ expect(genres({ active: true, file_url: "https://x/a.pdf", current_page: 5 }, { docUrl: "https://x/a.pdf" }))
39
+ .toEqual(["leave-map", "show-page"]);
40
+ });
41
+
42
+ it("ne fait rien d'un état absent ou vide", () => {
43
+ expect(genres(null)).toEqual(["nothing"]);
44
+ expect(genres(undefined)).toEqual(["nothing"]);
45
+ expect(genres({ active: true })).toEqual(["leave-map"]);
46
+ });
47
+ });
48
+
49
+ // ⚠️ LE point de sécurité de la bascule vers `broadcast` : sur la voie « table », le contenu venait
50
+ // de notre base, donc déjà assaini. Sur la voie « diffusion », il vient du NAVIGATEUR du
51
+ // présentateur — et rien n'empêche un participant d'émettre sur le canal. Sans re-validation, il
52
+ // imposerait une vue à toute l'audience.
53
+ describe("suivi de l'audience — un état diffusé n'est pas digne de confiance", () => {
54
+ it("re-valide le contenu reçu, quelle que soit sa provenance", () => {
55
+ const actions = presentationTransition({ active: true, content: { kind: "map", zoom: 9999, center: ["x", "y"] } });
56
+ expect(actions[0]).toMatchObject({ kind: "show-map" });
57
+ expect(actions[0]).toMatchObject({ content: { zoom: 21, center: [46.6, 2.5] } });
58
+ });
59
+
60
+ it("un contenu inconnu ramène au document au lieu d'obéir", () => {
61
+ for (const hostile of [{ kind: "iframe", src: "https://attaquant" }, { kind: 42 }, "map", []]) {
62
+ expect(genres({ active: true, content: hostile, current_page: 2 }), JSON.stringify(hostile))
63
+ .toEqual(["leave-map", "show-page"]);
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("rechargement d'un document changé", () => {
69
+ // Sans paramètre qui change, pdf.js ressert son cache : l'audience continue de voir l'ANCIEN
70
+ // document pendant que le présentateur commente le nouveau.
71
+ it("ajoute un cache-buster, en respectant la requête existante", () => {
72
+ expect(switchDocUrl("/api/doc?present=abc&file=1", "2026-08-13T10:00:00Z"))
73
+ .toBe("/api/doc?present=abc&file=1&v=2026-08-13T10%3A00%3A00Z");
74
+ expect(switchDocUrl("/fichier.pdf", "2026-08-13T10:00:00Z")).toBe("/fichier.pdf?v=2026-08-13T10%3A00%3A00Z");
75
+ });
76
+
77
+ it("reste distinct même sans horodatage", () => {
78
+ const a = switchDocUrl("/x?file=1");
79
+ expect(a).toMatch(/&v=\d+$/);
80
+ });
81
+ });
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTracker, type TrackerOptions } from "../tracking";
3
+
4
+ // Environnement navigateur minimal et PILOTABLE : c'est tout l'intérêt d'avoir sorti ce code des
5
+ // template literals — le temps, la visibilité et le focus deviennent des variables du test.
6
+ function harness(over: Partial<TrackerOptions> = {}) {
7
+ const sent: Array<Record<string, unknown>> = [];
8
+ const listeners = new Map<string, Set<() => void>>();
9
+ const timers: Array<{ fn: () => void; every: number; id: number }> = [];
10
+ let clock = 1_000_000;
11
+ let visible = true;
12
+ let focused = true;
13
+ let nextTimer = 1;
14
+
15
+ const target = {
16
+ addEventListener: (type: string, h: () => void) => {
17
+ if (!listeners.has(type)) listeners.set(type, new Set());
18
+ listeners.get(type)!.add(h);
19
+ },
20
+ removeEventListener: (type: string, h: () => void) => { listeners.get(type)?.delete(h); },
21
+ };
22
+ const doc = {
23
+ ...target,
24
+ get visibilityState() { return visible ? "visible" : "hidden"; },
25
+ hasFocus: () => focused,
26
+ } as unknown as Document;
27
+ const win = {
28
+ ...target,
29
+ setInterval: (fn: () => void, every: number) => { const id = nextTimer++; timers.push({ fn, every, id }); return id; },
30
+ clearInterval: (id: number) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
31
+ } as unknown as Window;
32
+
33
+ const tracker = createTracker({
34
+ doc, win,
35
+ now: () => clock,
36
+ send: (p) => sent.push(p),
37
+ sessionId: "sess-test",
38
+ ...over,
39
+ });
40
+
41
+ return {
42
+ tracker, sent, listeners, timers,
43
+ /** Avance le temps SANS déclencher les minuteries — le temps passe, on ne persiste pas. */
44
+ advance: (ms: number) => { clock += ms; },
45
+ fire: (type: string) => listeners.get(type)?.forEach((h) => h()),
46
+ hide: () => { visible = false; },
47
+ show: () => { visible = true; },
48
+ blur: () => { focused = false; },
49
+ focus: () => { focused = true; },
50
+ tickTimers: () => timers.slice().forEach((t) => t.fn()),
51
+ events: () => sent.map((p) => p.event),
52
+ };
53
+ }
54
+
55
+ describe("tracking — séparation des deux populations", () => {
56
+ it("un lien public journalise l'ouverture puis les pages atteintes", () => {
57
+ const h = harness({ slug: "Ab3-_xYz9012" });
58
+ h.tracker.start();
59
+ h.tracker.setPage(2);
60
+ expect(h.events()).toEqual(["open", "page"]);
61
+ expect(h.sent[1]).toMatchObject({ slug: "Ab3-_xYz9012", event: "page", page: 2, maxPage: 2 });
62
+ });
63
+
64
+ // Un aperçu interne qui journaliserait un `open` gonflerait l'entonnoir du prospect.
65
+ it("un aperçu interne n'émet AUCUN open ni page", () => {
66
+ const h = harness({ internal: { docId: "doc-1", email: "moi@example.fr", name: "Moi" } });
67
+ h.tracker.start();
68
+ h.tracker.setPage(2);
69
+ h.tracker.setPage(3);
70
+ expect(h.events()).toEqual([]);
71
+ h.tracker.flush();
72
+ expect(h.events()).toEqual(["session"]);
73
+ });
74
+
75
+ it("sans slug ni contexte interne, rien n'est envoyé du tout", () => {
76
+ const h = harness();
77
+ h.tracker.start();
78
+ h.tracker.setPage(4);
79
+ h.tracker.flush();
80
+ expect(h.sent).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("tracking — le temps ne compte que si on regarde vraiment", () => {
85
+ it("compte le temps passé, page par page", () => {
86
+ const h = harness({ slug: "s" });
87
+ h.tracker.start();
88
+ h.advance(4000);
89
+ h.tracker.setPage(2);
90
+ h.advance(6000);
91
+ h.tracker.flush();
92
+ const session = h.sent.find((p) => p.event === "session")!;
93
+ expect(session.pagesTime).toEqual({ 1: 4, 2: 6 });
94
+ expect(session.totalSeconds).toBe(10);
95
+ });
96
+
97
+ it("ne compte pas un onglet caché, et persiste en partant", () => {
98
+ const h = harness({ slug: "s" });
99
+ h.tracker.start();
100
+ h.advance(3000);
101
+ h.hide();
102
+ h.fire("visibilitychange");
103
+ expect(h.events()).toEqual(["open", "session"]); // caché ⇒ on persiste tout de suite
104
+ h.advance(600_000); // dix minutes dans un onglet oublié
105
+ h.show();
106
+ h.fire("visibilitychange");
107
+ h.advance(2000);
108
+ h.tracker.flush();
109
+ const last = h.sent[h.sent.length - 1];
110
+ expect(last.totalSeconds).toBe(5); // 3 + 2, les dix minutes cachées ne comptent pas
111
+ });
112
+
113
+ it("ne compte pas une fenêtre sans focus", () => {
114
+ const h = harness({ slug: "s" });
115
+ h.tracker.start();
116
+ h.advance(3000);
117
+ h.blur();
118
+ h.fire("blur");
119
+ h.advance(120_000);
120
+ h.focus();
121
+ h.fire("focus");
122
+ h.advance(1000);
123
+ h.tracker.flush();
124
+ expect(h.sent[h.sent.length - 1].totalSeconds).toBe(4);
125
+ });
126
+
127
+ it("met en pause après inactivité, et repart à la première interaction", () => {
128
+ const h = harness({ slug: "s", idleMs: 60_000 });
129
+ h.tracker.start();
130
+ h.advance(70_000);
131
+ h.tickTimers(); // la minuterie d'inactivité constate 70 s > 60 s
132
+ const afterIdle = h.tracker.totalSeconds();
133
+ h.advance(300_000);
134
+ expect(h.tracker.totalSeconds()).toBe(afterIdle); // le chrono est bien arrêté
135
+ h.tracker.noteActivity();
136
+ h.advance(5000);
137
+ expect(h.tracker.totalSeconds()).toBe(afterIdle + 5);
138
+ });
139
+ });
140
+
141
+ describe("tracking — progression dans le document", () => {
142
+ it("ne journalise une page qu'à la première visite (l'entonnoir mesure la progression)", () => {
143
+ const h = harness({ slug: "s" });
144
+ h.tracker.start();
145
+ h.tracker.setPage(2);
146
+ h.tracker.setPage(3);
147
+ h.tracker.setPage(2); // retour en arrière
148
+ h.tracker.setPage(3); // et re-avance
149
+ expect(h.events()).toEqual(["open", "page", "page"]);
150
+ expect(h.tracker.maxPage()).toBe(3);
151
+ });
152
+
153
+ it("ignore une page nulle ou identique à la courante", () => {
154
+ const h = harness({ slug: "s" });
155
+ h.tracker.start();
156
+ h.tracker.setPage(0);
157
+ h.tracker.setPage(2);
158
+ h.tracker.setPage(2);
159
+ expect(h.events()).toEqual(["open", "page"]);
160
+ });
161
+ });
162
+
163
+ describe("tracking — forme des charges utiles", () => {
164
+ it("session publique : rattachée au slug", () => {
165
+ const h = harness({ slug: "Ab3-_xYz9012" });
166
+ h.tracker.start();
167
+ h.tracker.setPageCount(12);
168
+ h.tracker.setPage(5);
169
+ h.advance(3000);
170
+ h.tracker.flush();
171
+ expect(h.sent[h.sent.length - 1]).toMatchObject({
172
+ event: "session", sessionId: "sess-test", slug: "Ab3-_xYz9012",
173
+ numPages: 12, maxPage: 5, totalSeconds: 3,
174
+ });
175
+ expect(h.sent[h.sent.length - 1].internal).toBeUndefined();
176
+ });
177
+
178
+ it("session interne : rattachée au document et au membre, jamais à un slug", () => {
179
+ const h = harness({ internal: { docId: "doc-1", email: "moi@example.fr", name: "Moi" } });
180
+ h.tracker.start();
181
+ h.tracker.setPageCount(4);
182
+ h.tracker.flush();
183
+ const p = h.sent[h.sent.length - 1];
184
+ expect(p).toMatchObject({ event: "session", internal: true, docId: "doc-1", email: "moi@example.fr", name: "Moi" });
185
+ expect(p.slug).toBeUndefined();
186
+ });
187
+ });
188
+
189
+ describe("tracking — cycle de vie", () => {
190
+ it("persiste périodiquement, puis plus rien après stop()", () => {
191
+ const h = harness({ slug: "s", sessionEveryMs: 12_000 });
192
+ h.tracker.start();
193
+ h.advance(12_000);
194
+ h.tickTimers();
195
+ expect(h.events().filter((e) => e === "session")).toHaveLength(1);
196
+
197
+ h.tracker.stop();
198
+ expect(h.timers).toHaveLength(0);
199
+ h.fire("beforeunload"); // écouteurs retirés → sans effet
200
+ expect(h.events().filter((e) => e === "session")).toHaveLength(1);
201
+ });
202
+
203
+ it("start() deux fois ne double pas les écouteurs", () => {
204
+ const h = harness({ slug: "s" });
205
+ h.tracker.start();
206
+ h.tracker.start();
207
+ expect(h.timers).toHaveLength(2); // inactivité + session, une seule fois
208
+ expect(h.events()).toEqual(["open"]);
209
+ });
210
+
211
+ // Perdre une mesure est ennuyeux ; perdre l'affichage du document est inacceptable.
212
+ it("un transport qui échoue n'interrompt pas la lecture", () => {
213
+ const h = harness({ slug: "s", send: () => { throw new Error("réseau coupé"); } });
214
+ expect(() => { h.tracker.start(); h.tracker.setPage(2); h.tracker.flush(); }).not.toThrow();
215
+ expect(h.tracker.maxPage()).toBe(2); // l'état interne reste juste
216
+ });
217
+ });