@glivion/square-screen-js-sdk 0.1.0 → 1.0.1
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/dist/index.cjs +874 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +522 -0
- package/dist/index.d.mts +522 -0
- package/dist/index.mjs +870 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +8 -1
- package/.github/workflows/build-js-sdk.yml +0 -70
- package/eslint.config.js +0 -3
- package/examples/react-app/README.md +0 -73
- package/examples/react-app/eslint.config.js +0 -22
- package/examples/react-app/index.html +0 -13
- package/examples/react-app/package-lock.json +0 -2239
- package/examples/react-app/package.json +0 -31
- package/examples/react-app/public/favicon.svg +0 -1
- package/examples/react-app/public/icons.svg +0 -24
- package/examples/react-app/src/App.css +0 -184
- package/examples/react-app/src/App.tsx +0 -157
- package/examples/react-app/src/EmergencyTicker.tsx +0 -25
- package/examples/react-app/src/HeadlessExample.tsx +0 -66
- package/examples/react-app/src/RendererExample.tsx +0 -70
- package/examples/react-app/src/assets/hero.png +0 -0
- package/examples/react-app/src/assets/react.svg +0 -1
- package/examples/react-app/src/assets/vite.svg +0 -1
- package/examples/react-app/src/index.css +0 -183
- package/examples/react-app/src/main.tsx +0 -10
- package/examples/react-app/src/mockNetworkDataSource.ts +0 -116
- package/examples/react-app/src/usePlayer.ts +0 -71
- package/examples/react-app/tsconfig.app.json +0 -25
- package/examples/react-app/tsconfig.json +0 -7
- package/examples/react-app/tsconfig.node.json +0 -24
- package/examples/react-app/vite.config.ts +0 -7
- package/examples/react-app/yarn.lock +0 -1089
- package/src/__tests__/cache/SquareScreenCache.test.ts +0 -375
- package/src/__tests__/network/NetworkClient.test.ts +0 -217
- package/src/__tests__/network/mappers.test.ts +0 -163
- package/src/__tests__/player/SquareScreenPlayer.test.ts +0 -840
- package/src/cache/SquareScreenCache.ts +0 -154
- package/src/constants.ts +0 -9
- package/src/core/types.ts +0 -251
- package/src/env.d.ts +0 -4
- package/src/index.ts +0 -34
- package/src/network/NetworkClient.ts +0 -234
- package/src/network/apiTypes.ts +0 -89
- package/src/network/mappers.ts +0 -106
- package/src/player/SquareScreenPlayer.ts +0 -414
- package/src/renderer/SquareScreenRenderer.ts +0 -282
- package/tsconfig.json +0 -12
- package/tsdown.config.ts +0 -23
|
@@ -1,840 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import type {
|
|
4
|
-
EmergencyAlert,
|
|
5
|
-
NetworkDataSource,
|
|
6
|
-
Playlist,
|
|
7
|
-
SquareScreenCacheProvider,
|
|
8
|
-
} from "../../core/types";
|
|
9
|
-
import { SquareScreenPlayer } from "../../player/SquareScreenPlayer";
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Factories
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
function makeItem(
|
|
16
|
-
overrides: Partial<Playlist["items"][number]> = {},
|
|
17
|
-
): Playlist["items"][number] {
|
|
18
|
-
return {
|
|
19
|
-
uuid: "item-1",
|
|
20
|
-
name: "Item 1",
|
|
21
|
-
type: "image",
|
|
22
|
-
url: "https://cdn.example.com/1.jpg",
|
|
23
|
-
duration: 5,
|
|
24
|
-
...overrides,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function makePlaylist(overrides: Partial<Playlist> = {}): Playlist {
|
|
29
|
-
return {
|
|
30
|
-
uuid: "playlist-1",
|
|
31
|
-
cachedAt: Date.now(),
|
|
32
|
-
strategy: { loop: true, shuffle: false, preloadCount: 0 },
|
|
33
|
-
items: [makeItem()],
|
|
34
|
-
...overrides,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function makeAlert(overrides: Partial<EmergencyAlert> = {}): EmergencyAlert {
|
|
39
|
-
return {
|
|
40
|
-
id: 1,
|
|
41
|
-
uuid: "alert-1",
|
|
42
|
-
companyId: 42,
|
|
43
|
-
title: "Emergency",
|
|
44
|
-
message: "Stay indoors",
|
|
45
|
-
backgroundColor: "#FF0000",
|
|
46
|
-
textColor: "#FFFFFF",
|
|
47
|
-
targetScope: "all",
|
|
48
|
-
isActive: true,
|
|
49
|
-
startedAt: new Date().toISOString(),
|
|
50
|
-
...overrides,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function makeNetwork(playlist: Playlist = makePlaylist()): NetworkDataSource {
|
|
55
|
-
return {
|
|
56
|
-
fetchPlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
|
|
57
|
-
fetchVideoPlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
|
|
58
|
-
fetchImagePlaylist: vi.fn().mockResolvedValue({ success: true, data: playlist }),
|
|
59
|
-
checkForEmergencyAlert: vi.fn().mockResolvedValue({ success: true, data: null }),
|
|
60
|
-
healthCheck: vi.fn().mockResolvedValue({ success: true, data: { received: true } }),
|
|
61
|
-
reportPlaybackEvent: vi
|
|
62
|
-
.fn()
|
|
63
|
-
.mockResolvedValue({ success: true, data: { recorded: true } }),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeCache(): SquareScreenCacheProvider {
|
|
68
|
-
return {
|
|
69
|
-
getPlaylist: vi.fn().mockResolvedValue(null),
|
|
70
|
-
savePlaylist: vi.fn().mockResolvedValue(undefined),
|
|
71
|
-
getMediaUrl: vi.fn().mockResolvedValue(null),
|
|
72
|
-
saveMedia: vi.fn().mockResolvedValue("blob:mock"),
|
|
73
|
-
clear: vi.fn().mockResolvedValue(undefined),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Shared config defaults — override per-test as needed. */
|
|
78
|
-
function makeConfig(
|
|
79
|
-
network: NetworkDataSource,
|
|
80
|
-
cache: SquareScreenCacheProvider,
|
|
81
|
-
overrides: Record<string, unknown> = {},
|
|
82
|
-
) {
|
|
83
|
-
return {
|
|
84
|
-
deviceId: "unused",
|
|
85
|
-
deviceToken: "unused",
|
|
86
|
-
version: "1.0.0",
|
|
87
|
-
pollInterval: 30_000,
|
|
88
|
-
emergencyPollInterval: 15_000,
|
|
89
|
-
heartbeatInterval: 60_000,
|
|
90
|
-
networkDataSource: network,
|
|
91
|
-
cacheProvider: cache,
|
|
92
|
-
...overrides,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// Setup / teardown
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
beforeEach(() => {
|
|
101
|
-
vi.useFakeTimers();
|
|
102
|
-
vi.stubGlobal(
|
|
103
|
-
"fetch",
|
|
104
|
-
vi.fn().mockResolvedValue({
|
|
105
|
-
ok: true,
|
|
106
|
-
blob: () => Promise.resolve(new Blob(["data"], { type: "image/jpeg" })),
|
|
107
|
-
}),
|
|
108
|
-
);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
afterEach(() => {
|
|
112
|
-
vi.clearAllTimers();
|
|
113
|
-
vi.useRealTimers();
|
|
114
|
-
vi.unstubAllGlobals();
|
|
115
|
-
vi.restoreAllMocks();
|
|
116
|
-
localStorage.clear();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
// Initialization guards
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
describe("initialization", () => {
|
|
124
|
-
it("throws if deviceId is missing and no networkDataSource is provided", () => {
|
|
125
|
-
expect(() => new SquareScreenPlayer({
|
|
126
|
-
deviceId: "",
|
|
127
|
-
deviceToken: "tok-1",
|
|
128
|
-
version: "1.0.0",
|
|
129
|
-
})).toThrow("deviceId is required");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("throws if deviceToken is missing and no networkDataSource is provided", () => {
|
|
133
|
-
expect(() => new SquareScreenPlayer({
|
|
134
|
-
deviceId: "dev-1",
|
|
135
|
-
deviceToken: "",
|
|
136
|
-
version: "1.0.0",
|
|
137
|
-
})).toThrow("deviceToken is required");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("does not throw when credentials are missing if networkDataSource is provided", () => {
|
|
141
|
-
expect(() => new SquareScreenPlayer({
|
|
142
|
-
deviceId: "",
|
|
143
|
-
deviceToken: "",
|
|
144
|
-
version: "1.0.0",
|
|
145
|
-
networkDataSource: makeNetwork(),
|
|
146
|
-
})).not.toThrow();
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// Startup
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
describe("startup", () => {
|
|
155
|
-
it("emits statuschange → online, playlistupdate and itemchange on a successful fetch", async () => {
|
|
156
|
-
const network = makeNetwork();
|
|
157
|
-
const cache = makeCache();
|
|
158
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
159
|
-
|
|
160
|
-
const statuschange = vi.fn();
|
|
161
|
-
const playlistupdate = vi.fn();
|
|
162
|
-
const itemchange = vi.fn();
|
|
163
|
-
player.addEventListener("statuschange", statuschange);
|
|
164
|
-
player.addEventListener("playlistupdate", playlistupdate);
|
|
165
|
-
player.addEventListener("itemchange", itemchange);
|
|
166
|
-
|
|
167
|
-
await player.start();
|
|
168
|
-
|
|
169
|
-
expect(statuschange).toHaveBeenCalledTimes(1);
|
|
170
|
-
expect(statuschange.mock.calls[0][0].detail.status).toBe("online");
|
|
171
|
-
expect(playlistupdate).toHaveBeenCalledTimes(1);
|
|
172
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
173
|
-
expect(player.currentItem?.uuid).toBe("item-1");
|
|
174
|
-
|
|
175
|
-
player.stop();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("emits statuschange → offline and no itemchange when the network fetch fails and there is no cached fallback", async () => {
|
|
179
|
-
const network = makeNetwork();
|
|
180
|
-
network.fetchPlaylist = vi
|
|
181
|
-
.fn()
|
|
182
|
-
.mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
|
|
183
|
-
|
|
184
|
-
const cache = makeCache();
|
|
185
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
186
|
-
|
|
187
|
-
const statuschange = vi.fn();
|
|
188
|
-
const itemchange = vi.fn();
|
|
189
|
-
player.addEventListener("statuschange", statuschange);
|
|
190
|
-
player.addEventListener("itemchange", itemchange);
|
|
191
|
-
|
|
192
|
-
await player.start();
|
|
193
|
-
|
|
194
|
-
expect(statuschange.mock.calls[0][0].detail.status).toBe("offline");
|
|
195
|
-
expect(itemchange).not.toHaveBeenCalled();
|
|
196
|
-
|
|
197
|
-
player.stop();
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("does not emit a duplicate statuschange when the status is unchanged between polls", async () => {
|
|
201
|
-
const network = makeNetwork();
|
|
202
|
-
const cache = makeCache();
|
|
203
|
-
const player = new SquareScreenPlayer(
|
|
204
|
-
makeConfig(network, cache, { pollInterval: 1_000 }),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const statuschange = vi.fn();
|
|
208
|
-
player.addEventListener("statuschange", statuschange);
|
|
209
|
-
|
|
210
|
-
await player.start();
|
|
211
|
-
expect(statuschange).toHaveBeenCalledTimes(1); // connecting → online
|
|
212
|
-
|
|
213
|
-
await vi.advanceTimersByTimeAsync(1_000); // trigger poll — still online
|
|
214
|
-
expect(statuschange).toHaveBeenCalledTimes(1); // no second event
|
|
215
|
-
|
|
216
|
-
player.stop();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("saves the playlist to cache with the configured TTL", async () => {
|
|
220
|
-
const network = makeNetwork();
|
|
221
|
-
const cache = makeCache();
|
|
222
|
-
const player = new SquareScreenPlayer(
|
|
223
|
-
makeConfig(network, cache, { ttl: 120_000 }),
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
await player.start();
|
|
227
|
-
|
|
228
|
-
expect(cache.savePlaylist).toHaveBeenCalledWith(
|
|
229
|
-
expect.objectContaining({ uuid: "playlist-1" }),
|
|
230
|
-
120_000,
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
player.stop();
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
// Media resolution
|
|
239
|
-
// ---------------------------------------------------------------------------
|
|
240
|
-
|
|
241
|
-
describe("media resolution", () => {
|
|
242
|
-
it("serves a blob URL from cache without hitting the network", async () => {
|
|
243
|
-
const network = makeNetwork();
|
|
244
|
-
const cache = makeCache();
|
|
245
|
-
vi.mocked(cache.getMediaUrl).mockResolvedValue("blob:cached");
|
|
246
|
-
|
|
247
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
248
|
-
const itemchange = vi.fn();
|
|
249
|
-
player.addEventListener("itemchange", itemchange);
|
|
250
|
-
|
|
251
|
-
await player.start();
|
|
252
|
-
|
|
253
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
254
|
-
expect(itemchange.mock.calls[0][0].detail.item.url).toBe("blob:cached");
|
|
255
|
-
|
|
256
|
-
player.stop();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("dispatches the raw URL immediately on a cache miss and downloads in background", async () => {
|
|
260
|
-
const network = makeNetwork();
|
|
261
|
-
const cache = makeCache();
|
|
262
|
-
|
|
263
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
264
|
-
const itemchange = vi.fn();
|
|
265
|
-
player.addEventListener("itemchange", itemchange);
|
|
266
|
-
|
|
267
|
-
await player.start();
|
|
268
|
-
|
|
269
|
-
// itemchange fires immediately with the raw URL — no inline fetch blocks it
|
|
270
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
271
|
-
expect(itemchange.mock.calls[0][0].detail.item.url).toBe("https://cdn.example.com/1.jpg");
|
|
272
|
-
|
|
273
|
-
// background download is triggered (fire-and-forget)
|
|
274
|
-
expect(fetch).toHaveBeenCalledWith("https://cdn.example.com/1.jpg", { mode: "cors" });
|
|
275
|
-
|
|
276
|
-
player.stop();
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("falls back to the raw URL when fetch throws", async () => {
|
|
280
|
-
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
|
281
|
-
|
|
282
|
-
const network = makeNetwork();
|
|
283
|
-
const cache = makeCache();
|
|
284
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
285
|
-
const itemchange = vi.fn();
|
|
286
|
-
player.addEventListener("itemchange", itemchange);
|
|
287
|
-
|
|
288
|
-
await player.start();
|
|
289
|
-
|
|
290
|
-
expect(itemchange.mock.calls[0][0].detail.item.url).toBe(
|
|
291
|
-
"https://cdn.example.com/1.jpg",
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
player.stop();
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("falls back to the raw URL when fetch returns a non-OK status", async () => {
|
|
298
|
-
vi.stubGlobal(
|
|
299
|
-
"fetch",
|
|
300
|
-
vi.fn().mockResolvedValue({ ok: false, status: 403, blob: vi.fn() }),
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
const network = makeNetwork();
|
|
304
|
-
const cache = makeCache();
|
|
305
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
306
|
-
const itemchange = vi.fn();
|
|
307
|
-
player.addEventListener("itemchange", itemchange);
|
|
308
|
-
|
|
309
|
-
await player.start();
|
|
310
|
-
|
|
311
|
-
expect(itemchange.mock.calls[0][0].detail.item.url).toBe(
|
|
312
|
-
"https://cdn.example.com/1.jpg",
|
|
313
|
-
);
|
|
314
|
-
expect(cache.saveMedia).not.toHaveBeenCalled();
|
|
315
|
-
|
|
316
|
-
player.stop();
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
// Offline fallback
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
|
|
324
|
-
describe("offline fallback", () => {
|
|
325
|
-
it("loads the stale cached playlist when the network fails", async () => {
|
|
326
|
-
const fallback = makePlaylist({
|
|
327
|
-
uuid: "cached-playlist",
|
|
328
|
-
items: [makeItem({ uuid: "cached-item", url: "https://cdn.example.com/cached.jpg" })],
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
const network = makeNetwork();
|
|
332
|
-
network.fetchPlaylist = vi
|
|
333
|
-
.fn()
|
|
334
|
-
.mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
|
|
335
|
-
|
|
336
|
-
const cache = makeCache();
|
|
337
|
-
vi.mocked(cache.getPlaylist).mockResolvedValueOnce(fallback);
|
|
338
|
-
localStorage.setItem("square-screen:last-playlist-uuid", "cached-playlist");
|
|
339
|
-
|
|
340
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
341
|
-
const itemchange = vi.fn();
|
|
342
|
-
player.addEventListener("itemchange", itemchange);
|
|
343
|
-
|
|
344
|
-
await player.start();
|
|
345
|
-
|
|
346
|
-
expect(cache.getPlaylist).toHaveBeenCalledWith("cached-playlist", true);
|
|
347
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
348
|
-
expect(player.currentItem?.uuid).toBe("cached-item");
|
|
349
|
-
|
|
350
|
-
player.stop();
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("emits no itemchange when the network fails and no last-UUID is stored", async () => {
|
|
354
|
-
const network = makeNetwork();
|
|
355
|
-
network.fetchPlaylist = vi
|
|
356
|
-
.fn()
|
|
357
|
-
.mockResolvedValue({ success: false, error: { kind: "network", code: 0, message: "down" } });
|
|
358
|
-
|
|
359
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
360
|
-
const itemchange = vi.fn();
|
|
361
|
-
player.addEventListener("itemchange", itemchange);
|
|
362
|
-
|
|
363
|
-
await player.start();
|
|
364
|
-
|
|
365
|
-
expect(itemchange).not.toHaveBeenCalled();
|
|
366
|
-
|
|
367
|
-
player.stop();
|
|
368
|
-
});
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// ---------------------------------------------------------------------------
|
|
372
|
-
// Item advancement
|
|
373
|
-
// ---------------------------------------------------------------------------
|
|
374
|
-
|
|
375
|
-
describe("item advancement", () => {
|
|
376
|
-
it("advances to the next item after the duration timer fires", async () => {
|
|
377
|
-
const playlist = makePlaylist({
|
|
378
|
-
items: [
|
|
379
|
-
makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
|
|
380
|
-
makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 3 }),
|
|
381
|
-
],
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
const network = makeNetwork(playlist);
|
|
385
|
-
const cache = makeCache();
|
|
386
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
387
|
-
const itemchange = vi.fn();
|
|
388
|
-
player.addEventListener("itemchange", itemchange);
|
|
389
|
-
|
|
390
|
-
await player.start();
|
|
391
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
392
|
-
expect(itemchange.mock.calls[0][0].detail.item.uuid).toBe("item-1");
|
|
393
|
-
|
|
394
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
395
|
-
|
|
396
|
-
expect(itemchange).toHaveBeenCalledTimes(2);
|
|
397
|
-
expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("item-2");
|
|
398
|
-
|
|
399
|
-
player.stop();
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it("loops back to item 0 after the last item when loop is true", async () => {
|
|
403
|
-
const playlist = makePlaylist({
|
|
404
|
-
items: [
|
|
405
|
-
makeItem({ uuid: "item-1", duration: 2 }),
|
|
406
|
-
makeItem({ uuid: "item-2", duration: 2 }),
|
|
407
|
-
],
|
|
408
|
-
strategy: { loop: true, shuffle: false, preloadCount: 0 },
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
const network = makeNetwork(playlist);
|
|
412
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
413
|
-
const itemchange = vi.fn();
|
|
414
|
-
player.addEventListener("itemchange", itemchange);
|
|
415
|
-
|
|
416
|
-
await player.start();
|
|
417
|
-
await vi.advanceTimersByTimeAsync(2_000); // → item-2
|
|
418
|
-
await vi.advanceTimersByTimeAsync(2_000); // → item-1 (loop)
|
|
419
|
-
|
|
420
|
-
expect(itemchange).toHaveBeenCalledTimes(3);
|
|
421
|
-
expect(itemchange.mock.calls[2][0].detail.item.uuid).toBe("item-1");
|
|
422
|
-
|
|
423
|
-
player.stop();
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("stops after the last item when loop is false", async () => {
|
|
427
|
-
const playlist = makePlaylist({
|
|
428
|
-
items: [
|
|
429
|
-
makeItem({ uuid: "item-1", duration: 2 }),
|
|
430
|
-
makeItem({ uuid: "item-2", duration: 2 }),
|
|
431
|
-
],
|
|
432
|
-
strategy: { loop: false, shuffle: false, preloadCount: 0 },
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const network = makeNetwork(playlist);
|
|
436
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
437
|
-
const itemchange = vi.fn();
|
|
438
|
-
player.addEventListener("itemchange", itemchange);
|
|
439
|
-
|
|
440
|
-
await player.start();
|
|
441
|
-
await vi.advanceTimersByTimeAsync(2_000); // → item-2
|
|
442
|
-
await vi.advanceTimersByTimeAsync(2_000); // end — no more itemchange
|
|
443
|
-
|
|
444
|
-
expect(itemchange).toHaveBeenCalledTimes(2);
|
|
445
|
-
expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("item-2");
|
|
446
|
-
|
|
447
|
-
player.stop();
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
it("reports a playback event to the network when advancing", async () => {
|
|
451
|
-
const playlist = makePlaylist({
|
|
452
|
-
items: [
|
|
453
|
-
makeItem({ uuid: "item-1", duration: 3 }),
|
|
454
|
-
makeItem({ uuid: "item-2", duration: 3 }),
|
|
455
|
-
],
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
const network = makeNetwork(playlist);
|
|
459
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
460
|
-
|
|
461
|
-
await player.start();
|
|
462
|
-
await vi.advanceTimersByTimeAsync(3_000);
|
|
463
|
-
|
|
464
|
-
expect(network.reportPlaybackEvent).toHaveBeenCalledWith(
|
|
465
|
-
expect.objectContaining({
|
|
466
|
-
media_uuid: "item-1",
|
|
467
|
-
playlist_uuid: "playlist-1",
|
|
468
|
-
completed: true,
|
|
469
|
-
}),
|
|
470
|
-
);
|
|
471
|
-
|
|
472
|
-
player.stop();
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it("emits itemchange with the correct index and total", async () => {
|
|
476
|
-
const playlist = makePlaylist({
|
|
477
|
-
items: [
|
|
478
|
-
makeItem({ uuid: "item-1", duration: 2 }),
|
|
479
|
-
makeItem({ uuid: "item-2", duration: 2 }),
|
|
480
|
-
makeItem({ uuid: "item-3", duration: 2 }),
|
|
481
|
-
],
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
const network = makeNetwork(playlist);
|
|
485
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
486
|
-
const itemchange = vi.fn();
|
|
487
|
-
player.addEventListener("itemchange", itemchange);
|
|
488
|
-
|
|
489
|
-
await player.start();
|
|
490
|
-
await vi.advanceTimersByTimeAsync(2_000);
|
|
491
|
-
|
|
492
|
-
const detail = itemchange.mock.calls[1][0].detail;
|
|
493
|
-
expect(detail.index).toBe(1);
|
|
494
|
-
expect(detail.total).toBe(3);
|
|
495
|
-
|
|
496
|
-
player.stop();
|
|
497
|
-
});
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
// ---------------------------------------------------------------------------
|
|
501
|
-
// Polling
|
|
502
|
-
// ---------------------------------------------------------------------------
|
|
503
|
-
|
|
504
|
-
describe("polling", () => {
|
|
505
|
-
it("calls fetchPlaylist again after the poll interval", async () => {
|
|
506
|
-
const network = makeNetwork();
|
|
507
|
-
const cache = makeCache();
|
|
508
|
-
const player = new SquareScreenPlayer(
|
|
509
|
-
makeConfig(network, cache, { pollInterval: 5_000 }),
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
await player.start();
|
|
513
|
-
expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
|
|
514
|
-
|
|
515
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
516
|
-
expect(network.fetchPlaylist).toHaveBeenCalledTimes(2);
|
|
517
|
-
|
|
518
|
-
player.stop();
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it("emits playlistupdate but not a new itemchange when the same UUID is re-fetched", async () => {
|
|
522
|
-
// Use a long duration so the item-advance timer doesn't fire before the poll.
|
|
523
|
-
const playlist = makePlaylist({ items: [makeItem({ duration: 3600 })] });
|
|
524
|
-
const network = makeNetwork(playlist);
|
|
525
|
-
const cache = makeCache();
|
|
526
|
-
const player = new SquareScreenPlayer(
|
|
527
|
-
makeConfig(network, cache, { pollInterval: 1_000 }),
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
const itemchange = vi.fn();
|
|
531
|
-
const playlistupdate = vi.fn();
|
|
532
|
-
player.addEventListener("itemchange", itemchange);
|
|
533
|
-
player.addEventListener("playlistupdate", playlistupdate);
|
|
534
|
-
|
|
535
|
-
await player.start();
|
|
536
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
537
|
-
expect(playlistupdate).toHaveBeenCalledTimes(1);
|
|
538
|
-
|
|
539
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
540
|
-
|
|
541
|
-
expect(playlistupdate).toHaveBeenCalledTimes(2);
|
|
542
|
-
expect(itemchange).toHaveBeenCalledTimes(1); // same UUID → no reset
|
|
543
|
-
|
|
544
|
-
player.stop();
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("restarts playback from item 0 when a new playlist UUID is fetched", async () => {
|
|
548
|
-
const first = makePlaylist({ uuid: "playlist-1", items: [makeItem({ duration: 3600 })] });
|
|
549
|
-
const second = makePlaylist({
|
|
550
|
-
uuid: "playlist-2",
|
|
551
|
-
items: [makeItem({ uuid: "new-item" })],
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
const network = makeNetwork(first);
|
|
555
|
-
const cache = makeCache();
|
|
556
|
-
const player = new SquareScreenPlayer(
|
|
557
|
-
makeConfig(network, cache, { pollInterval: 1_000 }),
|
|
558
|
-
);
|
|
559
|
-
|
|
560
|
-
const itemchange = vi.fn();
|
|
561
|
-
player.addEventListener("itemchange", itemchange);
|
|
562
|
-
|
|
563
|
-
await player.start();
|
|
564
|
-
expect(itemchange).toHaveBeenCalledTimes(1);
|
|
565
|
-
|
|
566
|
-
vi.mocked(network.fetchPlaylist).mockResolvedValue({ success: true, data: second });
|
|
567
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
568
|
-
|
|
569
|
-
expect(itemchange).toHaveBeenCalledTimes(2);
|
|
570
|
-
expect(itemchange.mock.calls[1][0].detail.item.uuid).toBe("new-item");
|
|
571
|
-
|
|
572
|
-
player.stop();
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it("refreshPlaylist() triggers an immediate fetch outside the poll interval", async () => {
|
|
576
|
-
const network = makeNetwork();
|
|
577
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
578
|
-
|
|
579
|
-
await player.start();
|
|
580
|
-
expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
|
|
581
|
-
|
|
582
|
-
player.refreshPlaylist();
|
|
583
|
-
await Promise.resolve();
|
|
584
|
-
|
|
585
|
-
expect(network.fetchPlaylist).toHaveBeenCalledTimes(2);
|
|
586
|
-
|
|
587
|
-
player.stop();
|
|
588
|
-
});
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
// ---------------------------------------------------------------------------
|
|
592
|
-
// Emergency alerts
|
|
593
|
-
// ---------------------------------------------------------------------------
|
|
594
|
-
|
|
595
|
-
describe("emergency alerts", () => {
|
|
596
|
-
it("does not emit emergencyalert when no alert is active", async () => {
|
|
597
|
-
const network = makeNetwork();
|
|
598
|
-
const player = new SquareScreenPlayer(makeConfig(network, makeCache()));
|
|
599
|
-
const emergencyalert = vi.fn();
|
|
600
|
-
player.addEventListener("emergencyalert", emergencyalert);
|
|
601
|
-
|
|
602
|
-
await player.start();
|
|
603
|
-
|
|
604
|
-
expect(emergencyalert).not.toHaveBeenCalled();
|
|
605
|
-
|
|
606
|
-
player.stop();
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
it("emits emergencyalert when an alert becomes active", async () => {
|
|
610
|
-
const alert = makeAlert();
|
|
611
|
-
const network = makeNetwork();
|
|
612
|
-
network.checkForEmergencyAlert = vi
|
|
613
|
-
.fn()
|
|
614
|
-
.mockResolvedValueOnce({ success: true, data: null })
|
|
615
|
-
.mockResolvedValue({ success: true, data: alert });
|
|
616
|
-
|
|
617
|
-
const player = new SquareScreenPlayer(
|
|
618
|
-
makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
|
|
619
|
-
);
|
|
620
|
-
const emergencyalert = vi.fn();
|
|
621
|
-
player.addEventListener("emergencyalert", emergencyalert);
|
|
622
|
-
|
|
623
|
-
await player.start();
|
|
624
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
625
|
-
|
|
626
|
-
expect(emergencyalert).toHaveBeenCalledTimes(1);
|
|
627
|
-
expect(emergencyalert.mock.calls[0][0].detail.alert.uuid).toBe("alert-1");
|
|
628
|
-
|
|
629
|
-
player.stop();
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
it("emits emergencyalert with null when the alert is cleared", async () => {
|
|
633
|
-
const alert = makeAlert();
|
|
634
|
-
const network = makeNetwork();
|
|
635
|
-
network.checkForEmergencyAlert = vi
|
|
636
|
-
.fn()
|
|
637
|
-
.mockResolvedValueOnce({ success: true, data: alert }) // startup → alert active
|
|
638
|
-
.mockResolvedValue({ success: true, data: null }); // poll → cleared
|
|
639
|
-
|
|
640
|
-
const player = new SquareScreenPlayer(
|
|
641
|
-
makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
|
|
642
|
-
);
|
|
643
|
-
const emergencyalert = vi.fn();
|
|
644
|
-
player.addEventListener("emergencyalert", emergencyalert);
|
|
645
|
-
|
|
646
|
-
await player.start(); // fires alert
|
|
647
|
-
await vi.advanceTimersByTimeAsync(1_000); // fires null (cleared)
|
|
648
|
-
|
|
649
|
-
expect(emergencyalert).toHaveBeenCalledTimes(2);
|
|
650
|
-
expect(emergencyalert.mock.calls[1][0].detail.alert).toBeNull();
|
|
651
|
-
|
|
652
|
-
player.stop();
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it("does not emit emergencyalert again when the same alert persists across polls", async () => {
|
|
656
|
-
const alert = makeAlert();
|
|
657
|
-
const network = makeNetwork();
|
|
658
|
-
network.checkForEmergencyAlert = vi
|
|
659
|
-
.fn()
|
|
660
|
-
.mockResolvedValue({ success: true, data: alert });
|
|
661
|
-
|
|
662
|
-
const player = new SquareScreenPlayer(
|
|
663
|
-
makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
|
|
664
|
-
);
|
|
665
|
-
const emergencyalert = vi.fn();
|
|
666
|
-
player.addEventListener("emergencyalert", emergencyalert);
|
|
667
|
-
|
|
668
|
-
await player.start();
|
|
669
|
-
await vi.advanceTimersByTimeAsync(3_000); // 3 more polls, same alert UUID
|
|
670
|
-
|
|
671
|
-
expect(emergencyalert).toHaveBeenCalledTimes(1); // only the initial activation
|
|
672
|
-
|
|
673
|
-
player.stop();
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
it("emits emergencyalert when the alert UUID changes", async () => {
|
|
677
|
-
const first = makeAlert({ uuid: "alert-1" });
|
|
678
|
-
const second = makeAlert({ uuid: "alert-2" });
|
|
679
|
-
const network = makeNetwork();
|
|
680
|
-
network.checkForEmergencyAlert = vi
|
|
681
|
-
.fn()
|
|
682
|
-
.mockResolvedValueOnce({ success: true, data: first })
|
|
683
|
-
.mockResolvedValue({ success: true, data: second });
|
|
684
|
-
|
|
685
|
-
const player = new SquareScreenPlayer(
|
|
686
|
-
makeConfig(network, makeCache(), { emergencyPollInterval: 1_000 }),
|
|
687
|
-
);
|
|
688
|
-
const emergencyalert = vi.fn();
|
|
689
|
-
player.addEventListener("emergencyalert", emergencyalert);
|
|
690
|
-
|
|
691
|
-
await player.start();
|
|
692
|
-
await vi.advanceTimersByTimeAsync(1_000);
|
|
693
|
-
|
|
694
|
-
expect(emergencyalert).toHaveBeenCalledTimes(2);
|
|
695
|
-
expect(emergencyalert.mock.calls[1][0].detail.alert.uuid).toBe("alert-2");
|
|
696
|
-
|
|
697
|
-
player.stop();
|
|
698
|
-
});
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
// ---------------------------------------------------------------------------
|
|
702
|
-
// Preloading
|
|
703
|
-
// ---------------------------------------------------------------------------
|
|
704
|
-
|
|
705
|
-
describe("preloading", () => {
|
|
706
|
-
it("does not trigger any background download when preloadCount is 0", async () => {
|
|
707
|
-
const playlist = makePlaylist({
|
|
708
|
-
items: [
|
|
709
|
-
makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg" }),
|
|
710
|
-
makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg" }),
|
|
711
|
-
],
|
|
712
|
-
strategy: { loop: true, shuffle: false, preloadCount: 0 },
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
const network = makeNetwork(playlist);
|
|
716
|
-
const cache = makeCache();
|
|
717
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
718
|
-
|
|
719
|
-
await player.start();
|
|
720
|
-
|
|
721
|
-
// Only item-1's URL should have been checked (for the current item).
|
|
722
|
-
expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/1.jpg");
|
|
723
|
-
expect(cache.getMediaUrl).not.toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
|
|
724
|
-
|
|
725
|
-
player.stop();
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
it("triggers a background download for the next item when preloadCount is 1", async () => {
|
|
729
|
-
const playlist = makePlaylist({
|
|
730
|
-
items: [
|
|
731
|
-
makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
|
|
732
|
-
makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 5 }),
|
|
733
|
-
],
|
|
734
|
-
strategy: { loop: true, shuffle: false, preloadCount: 1 },
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
const network = makeNetwork(playlist);
|
|
738
|
-
const cache = makeCache();
|
|
739
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
740
|
-
|
|
741
|
-
await player.start();
|
|
742
|
-
// Let the fire-and-forget getMediaUrl call for item-2 resolve.
|
|
743
|
-
await Promise.resolve();
|
|
744
|
-
|
|
745
|
-
expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
|
|
746
|
-
|
|
747
|
-
player.stop();
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it("preloads all items when preloadCount is undefined", async () => {
|
|
751
|
-
const playlist = makePlaylist({
|
|
752
|
-
items: [
|
|
753
|
-
makeItem({ uuid: "item-1", url: "https://cdn.example.com/1.jpg", duration: 5 }),
|
|
754
|
-
makeItem({ uuid: "item-2", url: "https://cdn.example.com/2.jpg", duration: 5 }),
|
|
755
|
-
makeItem({ uuid: "item-3", url: "https://cdn.example.com/3.jpg", duration: 5 }),
|
|
756
|
-
],
|
|
757
|
-
strategy: { loop: true, shuffle: false },
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
const network = makeNetwork(playlist);
|
|
761
|
-
const cache = makeCache();
|
|
762
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
763
|
-
|
|
764
|
-
await player.start();
|
|
765
|
-
await Promise.resolve();
|
|
766
|
-
|
|
767
|
-
expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/2.jpg");
|
|
768
|
-
expect(cache.getMediaUrl).toHaveBeenCalledWith("https://cdn.example.com/3.jpg");
|
|
769
|
-
|
|
770
|
-
player.stop();
|
|
771
|
-
});
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
// ---------------------------------------------------------------------------
|
|
775
|
-
// Heartbeat
|
|
776
|
-
// ---------------------------------------------------------------------------
|
|
777
|
-
|
|
778
|
-
describe("heartbeat", () => {
|
|
779
|
-
it("calls healthCheck after the heartbeat interval", async () => {
|
|
780
|
-
const network = makeNetwork();
|
|
781
|
-
const player = new SquareScreenPlayer(
|
|
782
|
-
makeConfig(network, makeCache(), { heartbeatInterval: 10_000 }),
|
|
783
|
-
);
|
|
784
|
-
|
|
785
|
-
await player.start();
|
|
786
|
-
expect(network.healthCheck).not.toHaveBeenCalled();
|
|
787
|
-
|
|
788
|
-
await vi.advanceTimersByTimeAsync(10_000);
|
|
789
|
-
|
|
790
|
-
expect(network.healthCheck).toHaveBeenCalledWith(
|
|
791
|
-
expect.objectContaining({ playerVersion: "1.0.0" }),
|
|
792
|
-
);
|
|
793
|
-
|
|
794
|
-
player.stop();
|
|
795
|
-
});
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
// ---------------------------------------------------------------------------
|
|
799
|
-
// stop()
|
|
800
|
-
// ---------------------------------------------------------------------------
|
|
801
|
-
|
|
802
|
-
describe("stop()", () => {
|
|
803
|
-
it("clears all timers so no further fetches or advances occur", async () => {
|
|
804
|
-
const network = makeNetwork();
|
|
805
|
-
const player = new SquareScreenPlayer(
|
|
806
|
-
makeConfig(network, makeCache(), { pollInterval: 5_000, heartbeatInterval: 10_000 }),
|
|
807
|
-
);
|
|
808
|
-
|
|
809
|
-
await player.start();
|
|
810
|
-
player.stop();
|
|
811
|
-
|
|
812
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
813
|
-
|
|
814
|
-
// Only the initial start() call — no subsequent polls or heartbeats.
|
|
815
|
-
expect(network.fetchPlaylist).toHaveBeenCalledTimes(1);
|
|
816
|
-
expect(network.healthCheck).not.toHaveBeenCalled();
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
it("prevents itemchange from firing when stop() is called before media resolves", async () => {
|
|
820
|
-
const network = makeNetwork();
|
|
821
|
-
const cache = makeCache();
|
|
822
|
-
|
|
823
|
-
let resolveMedia!: (url: string | null) => void;
|
|
824
|
-
vi.mocked(cache.getMediaUrl).mockReturnValue(
|
|
825
|
-
new Promise<string | null>((res) => { resolveMedia = res; }),
|
|
826
|
-
);
|
|
827
|
-
|
|
828
|
-
const player = new SquareScreenPlayer(makeConfig(network, cache));
|
|
829
|
-
const itemchange = vi.fn();
|
|
830
|
-
player.addEventListener("itemchange", itemchange);
|
|
831
|
-
|
|
832
|
-
const started = player.start();
|
|
833
|
-
player.stop();
|
|
834
|
-
resolveMedia(null);
|
|
835
|
-
await started;
|
|
836
|
-
await Promise.resolve();
|
|
837
|
-
|
|
838
|
-
expect(itemchange).not.toHaveBeenCalled();
|
|
839
|
-
});
|
|
840
|
-
});
|