@dnax/ui 0.0.64 → 0.0.66
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.
- package/components/QDatePicker.vue +12 -5
- package/components/QSpreadsheet.vue +51 -6
- package/components/QTable.vue +20 -6
- package/components/QTabs.vue +11 -2
- package/components/internal/QDateCalendar.vue +25 -23
- package/index.ts +16 -0
- package/lib/dateI18n.test.ts +91 -0
- package/lib/dateI18n.ts +85 -0
- package/lib/formula.test.ts +186 -1
- package/lib/formula.ts +442 -0
- package/lib/network.test.ts +199 -0
- package/lib/network.ts +147 -0
- package/lib/q.ts +6 -1
- package/lib/styleTokens.test.ts +46 -0
- package/lib/wakeLock.test.ts +219 -0
- package/lib/wakeLock.ts +202 -0
- package/package.json +1 -1
- package/runtime.ts +7 -0
- package/styles/main.css +119 -23
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// $q.network — état de connexion : logique pure + fabrique testable hors DOM.
|
|
2
|
+
// (La règle projet interdit le navigateur : on injecte `navigator` et la cible d'événements.)
|
|
3
|
+
import { describe, expect, it } from "bun:test"
|
|
4
|
+
import { effect } from "vue"
|
|
5
|
+
import { createNetwork, onlineAfterEvent, resolveOnline } from "./network"
|
|
6
|
+
|
|
7
|
+
/** Fausse cible d'événements (window) + compteur d'écouteurs. */
|
|
8
|
+
function fakeTarget() {
|
|
9
|
+
const listeners = new Map<string, Set<(event: { type: string }) => void>>()
|
|
10
|
+
return {
|
|
11
|
+
addEventListener(type: string, cb: (event: { type: string }) => void) {
|
|
12
|
+
if (!listeners.has(type)) listeners.set(type, new Set())
|
|
13
|
+
listeners.get(type)!.add(cb)
|
|
14
|
+
},
|
|
15
|
+
removeEventListener(type: string, cb: (event: { type: string }) => void) {
|
|
16
|
+
listeners.get(type)?.delete(cb)
|
|
17
|
+
},
|
|
18
|
+
/** Émet un événement comme le navigateur. */
|
|
19
|
+
emit(type: string) {
|
|
20
|
+
for (const cb of [...(listeners.get(type) ?? [])]) cb({ type })
|
|
21
|
+
},
|
|
22
|
+
/** Nombre d'écouteurs encore attachés pour ce type. */
|
|
23
|
+
count(type: string) {
|
|
24
|
+
return listeners.get(type)?.size ?? 0
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("resolveOnline", () => {
|
|
30
|
+
it("considère « en ligne » quand l'information manque (serveur, navigateur sans API)", () => {
|
|
31
|
+
expect(resolveOnline(undefined)).toBe(true)
|
|
32
|
+
expect(resolveOnline(null)).toBe(true)
|
|
33
|
+
expect(resolveOnline({})).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("suit navigator.onLine quand il est un booléen", () => {
|
|
37
|
+
expect(resolveOnline({ onLine: true })).toBe(true)
|
|
38
|
+
expect(resolveOnline({ onLine: false })).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("onlineAfterEvent", () => {
|
|
43
|
+
it("online → connecté, offline → déconnecté", () => {
|
|
44
|
+
expect(onlineAfterEvent("online", false)).toBe(true)
|
|
45
|
+
expect(onlineAfterEvent("offline", true)).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("un autre événement ne change rien", () => {
|
|
49
|
+
expect(onlineAfterEvent("focus", false)).toBe(false)
|
|
50
|
+
expect(onlineAfterEvent("focus", true)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("createNetwork", () => {
|
|
55
|
+
it("lit l'état initial dans navigator.onLine", () => {
|
|
56
|
+
const nav = { onLine: false }
|
|
57
|
+
const net = createNetwork({ navigator: nav, target: null })
|
|
58
|
+
expect(net.online).toBe(false)
|
|
59
|
+
expect(net.offline).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("sans cible (SSR), aucune écoute n'est posée et l'état reste stable", () => {
|
|
63
|
+
const net = createNetwork({ navigator: null, target: null })
|
|
64
|
+
expect(net.online).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("suit les événements online / offline de la cible", () => {
|
|
68
|
+
const target = fakeTarget()
|
|
69
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
70
|
+
|
|
71
|
+
target.emit("offline")
|
|
72
|
+
expect(net.online).toBe(false)
|
|
73
|
+
expect(net.offline).toBe(true)
|
|
74
|
+
|
|
75
|
+
target.emit("online")
|
|
76
|
+
expect(net.online).toBe(true)
|
|
77
|
+
expect(net.offline).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("est réactif : un effet est rejoué au changement (template `$q.network.online`)", () => {
|
|
81
|
+
const target = fakeTarget()
|
|
82
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
83
|
+
const seen: boolean[] = []
|
|
84
|
+
|
|
85
|
+
effect(() => {
|
|
86
|
+
seen.push(net.online)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
target.emit("offline")
|
|
90
|
+
target.emit("online")
|
|
91
|
+
|
|
92
|
+
expect(seen).toEqual([true, false, true])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("ne notifie qu'un vrai changement d'état", () => {
|
|
96
|
+
const target = fakeTarget()
|
|
97
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
98
|
+
let offlineCalls = 0
|
|
99
|
+
let onlineCalls = 0
|
|
100
|
+
net.on("offline", () => offlineCalls++)
|
|
101
|
+
net.on("online", () => onlineCalls++)
|
|
102
|
+
|
|
103
|
+
target.emit("offline")
|
|
104
|
+
target.emit("offline")
|
|
105
|
+
target.emit("online")
|
|
106
|
+
target.emit("online")
|
|
107
|
+
|
|
108
|
+
expect(offlineCalls).toBe(1)
|
|
109
|
+
expect(onlineCalls).toBe(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("on() cible le bon événement et rend un désabonnement", () => {
|
|
113
|
+
const target = fakeTarget()
|
|
114
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
115
|
+
const seen: string[] = []
|
|
116
|
+
const stopOnline = net.on("online", () => seen.push("online"))
|
|
117
|
+
net.on("offline", () => seen.push("offline"))
|
|
118
|
+
|
|
119
|
+
target.emit("offline")
|
|
120
|
+
target.emit("online")
|
|
121
|
+
stopOnline()
|
|
122
|
+
target.emit("offline")
|
|
123
|
+
target.emit("online")
|
|
124
|
+
|
|
125
|
+
expect(seen).toEqual(["offline", "online", "offline"])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("on() ignore un type inconnu (JS non typé)", () => {
|
|
129
|
+
const target = fakeTarget()
|
|
130
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
131
|
+
const stop = net.on("focus" as never, () => {
|
|
132
|
+
throw new Error("ne doit jamais être appelé")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
target.emit("offline")
|
|
136
|
+
expect(typeof stop).toBe("function")
|
|
137
|
+
stop()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("les abonnements se désabonnent", () => {
|
|
141
|
+
const target = fakeTarget()
|
|
142
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
143
|
+
let calls = 0
|
|
144
|
+
const off = net.on("offline", () => calls++)
|
|
145
|
+
|
|
146
|
+
target.emit("offline")
|
|
147
|
+
off()
|
|
148
|
+
target.emit("online")
|
|
149
|
+
target.emit("offline")
|
|
150
|
+
|
|
151
|
+
expect(calls).toBe(1)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("un abonné qui jette ne bloque pas les suivants", () => {
|
|
155
|
+
const target = fakeTarget()
|
|
156
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
157
|
+
let calls = 0
|
|
158
|
+
net.on("offline", () => {
|
|
159
|
+
throw new Error("boom")
|
|
160
|
+
})
|
|
161
|
+
net.on("offline", () => calls++)
|
|
162
|
+
|
|
163
|
+
target.emit("offline")
|
|
164
|
+
|
|
165
|
+
expect(calls).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("refresh() relit navigator.onLine (valeur devenue obsolète)", () => {
|
|
169
|
+
const nav = { onLine: true }
|
|
170
|
+
const net = createNetwork({ navigator: nav, target: fakeTarget() })
|
|
171
|
+
|
|
172
|
+
nav.onLine = false
|
|
173
|
+
expect(net.refresh()).toBe(false)
|
|
174
|
+
expect(net.online).toBe(false)
|
|
175
|
+
|
|
176
|
+
nav.onLine = true
|
|
177
|
+
expect(net.online).toBe(false) // pas de sondage automatique
|
|
178
|
+
expect(net.refresh()).toBe(true)
|
|
179
|
+
expect(net.online).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("destroy() détache les écouteurs et vide les abonnements", () => {
|
|
183
|
+
const target = fakeTarget()
|
|
184
|
+
const net = createNetwork({ navigator: { onLine: true }, target })
|
|
185
|
+
let calls = 0
|
|
186
|
+
net.on("offline", () => calls++)
|
|
187
|
+
|
|
188
|
+
expect(target.count("online")).toBe(1)
|
|
189
|
+
expect(target.count("offline")).toBe(1)
|
|
190
|
+
|
|
191
|
+
net.destroy()
|
|
192
|
+
target.emit("offline")
|
|
193
|
+
|
|
194
|
+
expect(target.count("online")).toBe(0)
|
|
195
|
+
expect(target.count("offline")).toBe(0)
|
|
196
|
+
expect(net.online).toBe(true)
|
|
197
|
+
expect(calls).toBe(0)
|
|
198
|
+
})
|
|
199
|
+
})
|
package/lib/network.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// $q.network — état de la connexion réseau (API Quasar-style, SSR-safe).
|
|
2
|
+
//
|
|
3
|
+
// Source unique : `navigator.onLine` + événements `online` / `offline` de `window`.
|
|
4
|
+
// L'objet exposé se lit **sans `.value`** (`$q.network.online`), donc directement dans
|
|
5
|
+
// un template, et reste **réactif** (ses getters lisent une ref interne).
|
|
6
|
+
//
|
|
7
|
+
// Le serveur n'a pas d'état réseau : au SSR `online` vaut `true` (pas de bannière
|
|
8
|
+
// « hors ligne » fantôme). Côté client, l'état réel de `navigator.onLine` est lu au
|
|
9
|
+
// chargement du module, avant hydratation — deux rendus ne diffèrent que si l'appareil
|
|
10
|
+
// est réellement hors ligne (comportement de `useOnline` de VueUse).
|
|
11
|
+
//
|
|
12
|
+
// La logique pure (`resolveOnline`, `onlineAfterEvent`) et la fabrique `createNetwork`
|
|
13
|
+
// (cible d'événements injectable) sont testables sans DOM.
|
|
14
|
+
import { reactive, ref } from "vue"
|
|
15
|
+
|
|
16
|
+
/** Sous-ensemble de `navigator` utilisé (injectable pour les tests). */
|
|
17
|
+
export interface NetworkNavigator {
|
|
18
|
+
onLine?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Cible des événements `online` / `offline` (`window` en production, objet factice en test). */
|
|
22
|
+
export interface NetworkEventTarget {
|
|
23
|
+
addEventListener: (type: string, listener: (event: { type: string }) => void) => void
|
|
24
|
+
removeEventListener?: (type: string, listener: (event: { type: string }) => void) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QNetwork {
|
|
28
|
+
/** Connecté au réseau — booléen réactif, se lit **sans `.value`** */
|
|
29
|
+
readonly online: boolean
|
|
30
|
+
/** Inversé de `online` (`v-if="$q.network.offline"`) */
|
|
31
|
+
readonly offline: boolean
|
|
32
|
+
/** Re-lit `navigator.onLine` (retour au premier plan…) et met à jour l'état */
|
|
33
|
+
refresh: () => boolean
|
|
34
|
+
/**
|
|
35
|
+
* Abonnement aux passages `"online"` / `"offline"` (transitions réelles seulement).
|
|
36
|
+
* Renvoie la fonction de désabonnement.
|
|
37
|
+
*/
|
|
38
|
+
on: (type: NetworkEvent, cb: () => void) => () => void
|
|
39
|
+
/** Détache les écouteurs (instance de test / usage hors singleton) */
|
|
40
|
+
destroy: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Événements réseau exposés par `$q.network.on()`. */
|
|
44
|
+
export type NetworkEvent = "online" | "offline"
|
|
45
|
+
|
|
46
|
+
export interface NetworkOptions {
|
|
47
|
+
/** Source de `navigator.onLine` (défaut : `navigator` global ; `null` au serveur) */
|
|
48
|
+
navigator?: NetworkNavigator | null
|
|
49
|
+
/** Cible des événements (défaut : `window` ; `null` au serveur) */
|
|
50
|
+
target?: NetworkEventTarget | null
|
|
51
|
+
/** Force la valeur initiale (sinon `resolveOnline(navigator)`) */
|
|
52
|
+
initial?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `true` par défaut : le serveur et les navigateurs sans `navigator.onLine` sont
|
|
57
|
+
* considérés **en ligne** (évite une bannière « hors ligne » au chargement SSR).
|
|
58
|
+
*/
|
|
59
|
+
export function resolveOnline(nav?: NetworkNavigator | null): boolean {
|
|
60
|
+
return typeof nav?.onLine === "boolean" ? nav.onLine : true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** État après un événement : `online` → true, `offline` → false, autre → inchangé. */
|
|
64
|
+
export function onlineAfterEvent(type: string, current: boolean): boolean {
|
|
65
|
+
if (type === "online") return true
|
|
66
|
+
if (type === "offline") return false
|
|
67
|
+
return current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Crée un objet réseau réactif. Sans `target`, aucun écouteur n'est posé (SSR) ;
|
|
72
|
+
* sans `navigator`, l'état initial vaut `true`.
|
|
73
|
+
*/
|
|
74
|
+
export function createNetwork(options: NetworkOptions = {}): QNetwork {
|
|
75
|
+
const nav = "navigator" in options
|
|
76
|
+
? (options.navigator ?? null)
|
|
77
|
+
: typeof navigator !== "undefined"
|
|
78
|
+
? navigator
|
|
79
|
+
: null
|
|
80
|
+
const target = "target" in options
|
|
81
|
+
? (options.target ?? null)
|
|
82
|
+
: typeof window !== "undefined"
|
|
83
|
+
? window
|
|
84
|
+
: null
|
|
85
|
+
|
|
86
|
+
const online = ref(options.initial ?? resolveOnline(nav))
|
|
87
|
+
const listeners: Record<"online" | "offline", Set<() => void>> = {
|
|
88
|
+
online: new Set(),
|
|
89
|
+
offline: new Set(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const setOnline = (next: boolean) => {
|
|
93
|
+
if (online.value === next) return
|
|
94
|
+
online.value = next
|
|
95
|
+
for (const cb of [...listeners[next ? "online" : "offline"]]) {
|
|
96
|
+
try {
|
|
97
|
+
cb()
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Un abonné fautif ne doit pas empêcher les autres d'être notifiés.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleEvent = (event: { type: string }) => {
|
|
106
|
+
setOnline(onlineAfterEvent(event.type, online.value))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const refresh = () => {
|
|
110
|
+
const next = resolveOnline(nav)
|
|
111
|
+
setOnline(next)
|
|
112
|
+
return next
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const on = (type: NetworkEvent, cb: () => void) => {
|
|
116
|
+
// Garde runtime : un type inconnu (JS non typé) renvoie un désabonnement inerte.
|
|
117
|
+
if (type !== "online" && type !== "offline") return () => {}
|
|
118
|
+
listeners[type].add(cb)
|
|
119
|
+
return () => {
|
|
120
|
+
listeners[type].delete(cb)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
target?.addEventListener("online", handleEvent)
|
|
125
|
+
target?.addEventListener("offline", handleEvent)
|
|
126
|
+
|
|
127
|
+
return reactive({
|
|
128
|
+
/** @see QNetwork.online */
|
|
129
|
+
get online() {
|
|
130
|
+
return online.value
|
|
131
|
+
},
|
|
132
|
+
get offline() {
|
|
133
|
+
return !online.value
|
|
134
|
+
},
|
|
135
|
+
refresh,
|
|
136
|
+
on,
|
|
137
|
+
destroy: () => {
|
|
138
|
+
target?.removeEventListener?.("online", handleEvent)
|
|
139
|
+
target?.removeEventListener?.("offline", handleEvent)
|
|
140
|
+
listeners.online.clear()
|
|
141
|
+
listeners.offline.clear()
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Singleton exposé sous `$q.network` (et importable directement depuis `@dnax/ui`). */
|
|
147
|
+
export const network: QNetwork = createNetwork()
|
package/lib/q.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { platform, qBreakpoints } from "./platform"
|
|
|
15
15
|
import { screen } from "./screen"
|
|
16
16
|
import { loading } from "./loading"
|
|
17
17
|
import { localStorage, sessionStorage } from "./storage"
|
|
18
|
+
import { network } from "./network"
|
|
19
|
+
import { wakeLock } from "./wakeLock"
|
|
18
20
|
|
|
19
21
|
export interface DialogOptions {
|
|
20
22
|
/** Composant de dialogue (SFC importé) ou nom de composant global */
|
|
@@ -324,7 +326,8 @@ function notify(opts: NotifyOptions): NotifyController {
|
|
|
324
326
|
}
|
|
325
327
|
|
|
326
328
|
// API publique : $q.dialog / $q.bottomSheet / $q.notify / $q.imagePreview / $q.platform / $q.breakpoints / $q.screen / $q.loading
|
|
327
|
-
// / $q.localStorage / $q.sessionStorage (stockage web typé)
|
|
329
|
+
// / $q.localStorage / $q.sessionStorage (stockage web typé) / $q.network (connexion en ligne)
|
|
330
|
+
// / $q.wakeLock (écran allumé, Screen Wake Lock API)
|
|
328
331
|
// Conventions Quasar : .open() pour les piles (dialog, bottomSheet, imagePreview),
|
|
329
332
|
// .show() pour notify et loading ; méthodes directes pour le stockage.
|
|
330
333
|
const notifyPlugin = notify as typeof notify & { show: typeof notify }
|
|
@@ -347,6 +350,8 @@ export const $q = {
|
|
|
347
350
|
loading,
|
|
348
351
|
localStorage,
|
|
349
352
|
sessionStorage,
|
|
353
|
+
network,
|
|
354
|
+
wakeLock,
|
|
350
355
|
}
|
|
351
356
|
|
|
352
357
|
/** Accès programmatique aux plugins ($q.dialog, $q.notify, $q.loading…) */
|
package/lib/styleTokens.test.ts
CHANGED
|
@@ -102,6 +102,17 @@ describe("classes de style (composant ↔ CSS)", () => {
|
|
|
102
102
|
],
|
|
103
103
|
["components/QCollapse.vue", "q-collapse--guide", ".q-collapse--guide .q-collapse__inner"],
|
|
104
104
|
["components/QTabs.vue", "q-tabs--arrows", ".q-tabs--arrows .q-tabs__content"],
|
|
105
|
+
[
|
|
106
|
+
"components/QTabs.vue",
|
|
107
|
+
"q-tabs--scroll-start",
|
|
108
|
+
".q-tabs--arrows.q-tabs--scroll-start .q-tabs__content",
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
"components/QTabs.vue",
|
|
112
|
+
"q-tabs--scroll-end",
|
|
113
|
+
".q-tabs--arrows.q-tabs--scroll-end .q-tabs__content",
|
|
114
|
+
],
|
|
115
|
+
["components/QTable.vue", "q-table__rows-field", ".q-table__rows-field"],
|
|
105
116
|
]
|
|
106
117
|
|
|
107
118
|
for (const [file, className, selector] of CONTRACTS) {
|
|
@@ -129,6 +140,41 @@ describe("classes de style (composant ↔ CSS)", () => {
|
|
|
129
140
|
})
|
|
130
141
|
})
|
|
131
142
|
|
|
143
|
+
describe("fondu de défilement des tabs (thème-agnostique)", () => {
|
|
144
|
+
const block = blockAfter(css, ".q-tabs--arrows .q-tabs__content")
|
|
145
|
+
|
|
146
|
+
it("le fondu est un masque alpha (`#fff`), sans couleur de thème", () => {
|
|
147
|
+
// `#fff` est opaque en mode `alpha` ET `luminance` (ancien WebKit).
|
|
148
|
+
expect({ defined: block !== undefined }).toEqual({ defined: true })
|
|
149
|
+
expect({ hasMask: block!.includes("mask-image") }).toEqual({ hasMask: true })
|
|
150
|
+
expect({ opaque: block!.includes("#fff") }).toEqual({ opaque: true })
|
|
151
|
+
// Aucune couleur de surface : le fondu ne peut pas se désaligner d'un thème à l'autre.
|
|
152
|
+
expect({ themeColor: block!.includes("var(--card") }).toEqual({ themeColor: false })
|
|
153
|
+
expect({ hardcodedBlack: block!.includes("rgb(0 0 0") }).toEqual({ hardcodedBlack: false })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("le fondu suit l'axe des tabs (horizontal → to right, vertical → to bottom)", () => {
|
|
157
|
+
const vertical = blockAfter(css, ".q-tabs--vertical.q-tabs--arrows .q-tabs__content")
|
|
158
|
+
expect({ defined: vertical !== undefined }).toEqual({ defined: true })
|
|
159
|
+
expect({ right: block!.includes("to right") }).toEqual({ right: true })
|
|
160
|
+
expect({ bottom: vertical!.includes("to bottom") }).toEqual({ bottom: true })
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe("pagination de QTable", () => {
|
|
165
|
+
it("le sélecteur « lignes par page » est un QSelect, pas un `<select>` natif", async () => {
|
|
166
|
+
const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
|
|
167
|
+
expect({ usesQSelect: vue.includes("<q-select") }).toEqual({ usesQSelect: true })
|
|
168
|
+
expect({ nativeSelect: /<select\b/.test(vue) }).toEqual({ nativeSelect: false })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("la liste vient de la prop publique `rows-per-page-options` (aucun tableau dérivé)", async () => {
|
|
172
|
+
const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
|
|
173
|
+
expect({ fromProp: vue.includes(':options="rowsPerPageOptions"') }).toEqual({ fromProp: true })
|
|
174
|
+
expect({ derived: vue.includes("rowsPerPageChoices") }).toEqual({ derived: false })
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
132
178
|
describe("mode sombre des contrôles", () => {
|
|
133
179
|
it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
|
|
134
180
|
// Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// $q.wakeLock — Screen Wake Lock : logique pure + fabrique testable hors navigateur.
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import {
|
|
4
|
+
createWakeLock,
|
|
5
|
+
isDocumentVisible,
|
|
6
|
+
isWakeLockCapable,
|
|
7
|
+
shouldReacquire,
|
|
8
|
+
} from "./wakeLock"
|
|
9
|
+
|
|
10
|
+
class FakeSentinel {
|
|
11
|
+
released = false
|
|
12
|
+
releaseCalls = 0
|
|
13
|
+
private listeners = new Set<() => void>()
|
|
14
|
+
|
|
15
|
+
async release() {
|
|
16
|
+
this.releaseCalls++
|
|
17
|
+
this.released = true
|
|
18
|
+
for (const cb of [...this.listeners]) cb()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
addEventListener(_type: "release", cb: () => void) {
|
|
22
|
+
this.listeners.add(cb)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
removeEventListener(_type: "release", cb: () => void) {
|
|
26
|
+
this.listeners.delete(cb)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Simule le relâchement décidé par le navigateur (page masquée, veille…). */
|
|
30
|
+
autoRelease() {
|
|
31
|
+
this.released = true
|
|
32
|
+
for (const cb of [...this.listeners]) cb()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fakeApi(options: { fail?: Error } = {}) {
|
|
37
|
+
const sentinels: FakeSentinel[] = []
|
|
38
|
+
return {
|
|
39
|
+
calls: 0,
|
|
40
|
+
sentinels,
|
|
41
|
+
async request(_type: "screen") {
|
|
42
|
+
this.calls++
|
|
43
|
+
if (options.fail) throw options.fail
|
|
44
|
+
const s = new FakeSentinel()
|
|
45
|
+
sentinels.push(s)
|
|
46
|
+
return s
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeVisibility(state = "visible") {
|
|
52
|
+
const listeners = new Set<() => void>()
|
|
53
|
+
return {
|
|
54
|
+
visibilityState: state as string,
|
|
55
|
+
addEventListener(_type: "visibilitychange", cb: () => void) {
|
|
56
|
+
listeners.add(cb)
|
|
57
|
+
},
|
|
58
|
+
removeEventListener(_type: "visibilitychange", cb: () => void) {
|
|
59
|
+
listeners.delete(cb)
|
|
60
|
+
},
|
|
61
|
+
set(next: string) {
|
|
62
|
+
this.visibilityState = next
|
|
63
|
+
for (const cb of [...listeners]) cb()
|
|
64
|
+
},
|
|
65
|
+
count() {
|
|
66
|
+
return listeners.size
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function client(options: Partial<Parameters<typeof createWakeLock>[0]> = {}) {
|
|
72
|
+
return createWakeLock({
|
|
73
|
+
server: false,
|
|
74
|
+
deferCapability: false,
|
|
75
|
+
api: fakeApi(),
|
|
76
|
+
visibility: fakeVisibility(),
|
|
77
|
+
...options,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("isWakeLockCapable", () => {
|
|
82
|
+
it("exige une API avec request()", () => {
|
|
83
|
+
expect(isWakeLockCapable(null)).toBe(false)
|
|
84
|
+
expect(isWakeLockCapable(undefined)).toBe(false)
|
|
85
|
+
expect(isWakeLockCapable({} as never)).toBe(false)
|
|
86
|
+
expect(isWakeLockCapable({ request: async () => new FakeSentinel() })).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("isDocumentVisible", () => {
|
|
91
|
+
it("visible sauf `visibilityState: 'hidden'` (et sans document injecté)", () => {
|
|
92
|
+
expect(isDocumentVisible(null)).toBe(true)
|
|
93
|
+
expect(isDocumentVisible({ addEventListener() {} })).toBe(true)
|
|
94
|
+
expect(
|
|
95
|
+
isDocumentVisible({ visibilityState: "visible", addEventListener() {} }),
|
|
96
|
+
).toBe(true)
|
|
97
|
+
expect(
|
|
98
|
+
isDocumentVisible({ visibilityState: "hidden", addEventListener() {} }),
|
|
99
|
+
).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("shouldReacquire", () => {
|
|
104
|
+
it("uniquement si demandé, visible et rien de tenu", () => {
|
|
105
|
+
expect(shouldReacquire(true, true, false)).toBe(true)
|
|
106
|
+
expect(shouldReacquire(false, true, false)).toBe(false)
|
|
107
|
+
expect(shouldReacquire(true, false, false)).toBe(false)
|
|
108
|
+
expect(shouldReacquire(true, true, true)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("createWakeLock", () => {
|
|
113
|
+
it("au serveur : méthodes sans effet, rien de capable ni d'actif", async () => {
|
|
114
|
+
const lock = createWakeLock({ server: true })
|
|
115
|
+
expect(lock.isCapable).toBe(false)
|
|
116
|
+
expect(lock.isActive).toBe(false)
|
|
117
|
+
await expect(lock.request()).resolves.toBeUndefined()
|
|
118
|
+
await expect(lock.release()).resolves.toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("sans API : `Not capable`", async () => {
|
|
122
|
+
const lock = client({ api: null })
|
|
123
|
+
expect(lock.isCapable).toBe(false)
|
|
124
|
+
await expect(lock.request()).rejects.toThrow("Not capable")
|
|
125
|
+
expect(lock.isActive).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("request() acquiert le verrou, une seule fois (idempotent)", async () => {
|
|
129
|
+
const api = fakeApi()
|
|
130
|
+
const lock = client({ api })
|
|
131
|
+
|
|
132
|
+
await lock.request()
|
|
133
|
+
expect(lock.isActive).toBe(true)
|
|
134
|
+
expect(api.calls).toBe(1)
|
|
135
|
+
|
|
136
|
+
await lock.request()
|
|
137
|
+
expect(api.calls).toBe(1)
|
|
138
|
+
expect(lock.isActive).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("release() rend la main au système", async () => {
|
|
142
|
+
const api = fakeApi()
|
|
143
|
+
const lock = client({ api })
|
|
144
|
+
|
|
145
|
+
await lock.request()
|
|
146
|
+
await lock.release()
|
|
147
|
+
|
|
148
|
+
expect(lock.isActive).toBe(false)
|
|
149
|
+
expect(api.sentinels[0]!.releaseCalls).toBe(1)
|
|
150
|
+
|
|
151
|
+
// Une nouvelle demande fonctionne.
|
|
152
|
+
await lock.request()
|
|
153
|
+
expect(api.calls).toBe(2)
|
|
154
|
+
expect(lock.isActive).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("le navigateur relâche → isActive retombe, et le verrou est redemandé au retour", async () => {
|
|
158
|
+
const api = fakeApi()
|
|
159
|
+
const visibility = fakeVisibility("visible")
|
|
160
|
+
const lock = client({ api, visibility })
|
|
161
|
+
|
|
162
|
+
await lock.request()
|
|
163
|
+
expect(lock.isActive).toBe(true)
|
|
164
|
+
|
|
165
|
+
// Onglet masqué : le navigateur relâche de lui-même.
|
|
166
|
+
visibility.set("hidden")
|
|
167
|
+
api.sentinels[0]!.autoRelease()
|
|
168
|
+
expect(lock.isActive).toBe(false)
|
|
169
|
+
expect(api.calls).toBe(1)
|
|
170
|
+
|
|
171
|
+
// Retour de visibilité : la demande mémorisée est rejouée.
|
|
172
|
+
visibility.set("visible")
|
|
173
|
+
await Promise.resolve()
|
|
174
|
+
expect(api.calls).toBe(2)
|
|
175
|
+
expect(lock.isActive).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("après release(), plus aucune redemande au changement de visibilité", async () => {
|
|
179
|
+
const api = fakeApi()
|
|
180
|
+
const visibility = fakeVisibility("visible")
|
|
181
|
+
const lock = client({ api, visibility })
|
|
182
|
+
|
|
183
|
+
await lock.request()
|
|
184
|
+
await lock.release()
|
|
185
|
+
|
|
186
|
+
visibility.set("hidden")
|
|
187
|
+
visibility.set("visible")
|
|
188
|
+
await Promise.resolve()
|
|
189
|
+
|
|
190
|
+
expect(api.calls).toBe(1)
|
|
191
|
+
expect(lock.isActive).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("le refus du navigateur est propagé (échec doux côté appelant)", async () => {
|
|
195
|
+
const api = fakeApi({ fail: new Error("denied") })
|
|
196
|
+
const lock = client({ api })
|
|
197
|
+
|
|
198
|
+
await expect(lock.request()).rejects.toThrow("denied")
|
|
199
|
+
expect(lock.isActive).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("destroy() détache l'écouteur de visibilité", async () => {
|
|
203
|
+
const visibility = fakeVisibility()
|
|
204
|
+
const lock = client({ visibility })
|
|
205
|
+
|
|
206
|
+
expect(visibility.count()).toBe(1)
|
|
207
|
+
lock.destroy()
|
|
208
|
+
expect(visibility.count()).toBe(0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("isCapable n'est révélé qu'après hydratation (report d'un tick)", async () => {
|
|
212
|
+
const api = fakeApi()
|
|
213
|
+
const lock = createWakeLock({ server: false, api, visibility: null })
|
|
214
|
+
|
|
215
|
+
expect(lock.isCapable).toBe(false)
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
217
|
+
expect(lock.isCapable).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
})
|