@editframe/elements 0.19.2-beta.0 → 0.20.0-beta.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.
- package/dist/elements/ContextProxiesController.d.ts +40 -0
- package/dist/elements/ContextProxiesController.js +69 -0
- package/dist/elements/EFCaptions.d.ts +45 -6
- package/dist/elements/EFCaptions.js +220 -26
- package/dist/elements/EFImage.js +4 -1
- package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
- package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
- package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
- package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
- package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
- package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
- package/dist/elements/EFMedia/JitMediaEngine.js +24 -0
- package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
- package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
- package/dist/elements/EFMedia.js +25 -1
- package/dist/elements/EFSurface.browsertest.d.ts +0 -0
- package/dist/elements/EFSurface.d.ts +30 -0
- package/dist/elements/EFSurface.js +96 -0
- package/dist/elements/EFTemporal.js +7 -6
- package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
- package/dist/elements/EFThumbnailStrip.d.ts +86 -0
- package/dist/elements/EFThumbnailStrip.js +490 -0
- package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
- package/dist/elements/EFTimegroup.d.ts +7 -7
- package/dist/elements/EFTimegroup.js +59 -16
- package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
- package/dist/elements/updateAnimations.d.ts +5 -0
- package/dist/elements/updateAnimations.js +37 -13
- package/dist/getRenderInfo.js +1 -1
- package/dist/gui/ContextMixin.js +27 -14
- package/dist/gui/EFControls.browsertest.d.ts +0 -0
- package/dist/gui/EFControls.d.ts +38 -0
- package/dist/gui/EFControls.js +51 -0
- package/dist/gui/EFFilmstrip.d.ts +40 -1
- package/dist/gui/EFFilmstrip.js +240 -3
- package/dist/gui/EFPreview.js +2 -1
- package/dist/gui/EFScrubber.d.ts +6 -5
- package/dist/gui/EFScrubber.js +31 -21
- package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
- package/dist/gui/EFTimeDisplay.d.ts +2 -6
- package/dist/gui/EFTimeDisplay.js +13 -23
- package/dist/gui/TWMixin.js +1 -1
- package/dist/gui/currentTimeContext.d.ts +3 -0
- package/dist/gui/currentTimeContext.js +3 -0
- package/dist/gui/durationContext.d.ts +3 -0
- package/dist/gui/durationContext.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/dist/style.css +1 -1
- package/dist/transcoding/types/index.d.ts +11 -0
- package/dist/utils/LRUCache.d.ts +46 -0
- package/dist/utils/LRUCache.js +382 -1
- package/dist/utils/LRUCache.test.d.ts +1 -0
- package/package.json +2 -2
- package/src/elements/ContextProxiesController.ts +123 -0
- package/src/elements/EFCaptions.browsertest.ts +1820 -0
- package/src/elements/EFCaptions.ts +373 -36
- package/src/elements/EFImage.ts +4 -1
- package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
- package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
- package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
- package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
- package/src/elements/EFMedia/JitMediaEngine.ts +48 -0
- package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
- package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
- package/src/elements/EFMedia.ts +38 -1
- package/src/elements/EFSurface.browsertest.ts +155 -0
- package/src/elements/EFSurface.ts +141 -0
- package/src/elements/EFTemporal.ts +14 -8
- package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
- package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
- package/src/elements/EFThumbnailStrip.ts +905 -0
- package/src/elements/EFTimegroup.browsertest.ts +56 -7
- package/src/elements/EFTimegroup.ts +88 -18
- package/src/elements/updateAnimations.browsertest.ts +361 -12
- package/src/elements/updateAnimations.ts +68 -19
- package/src/gui/ContextMixin.browsertest.ts +0 -25
- package/src/gui/ContextMixin.ts +44 -20
- package/src/gui/EFControls.browsertest.ts +175 -0
- package/src/gui/EFControls.ts +84 -0
- package/src/gui/EFFilmstrip.ts +323 -4
- package/src/gui/EFPreview.ts +2 -1
- package/src/gui/EFScrubber.ts +29 -25
- package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
- package/src/gui/EFTimeDisplay.ts +12 -40
- package/src/gui/currentTimeContext.ts +5 -0
- package/src/gui/durationContext.ts +3 -0
- package/src/transcoding/types/index.ts +13 -0
- package/src/utils/LRUCache.test.ts +272 -0
- package/src/utils/LRUCache.ts +543 -0
- package/types.json +1 -1
- package/dist/transcoding/cache/CacheManager.d.ts +0 -73
- package/src/transcoding/cache/CacheManager.ts +0 -208
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { html, render as litRender, type TemplateResult } from "lit";
|
|
2
|
+
import { assert, beforeEach, describe, test, vi } from "vitest";
|
|
3
|
+
import { EFTimeDisplay } from "./EFTimeDisplay.js";
|
|
4
|
+
import "./EFTimeDisplay.js";
|
|
5
|
+
import type { EFTimegroup } from "../elements/EFTimegroup.js";
|
|
6
|
+
import "../elements/EFTimegroup.js";
|
|
7
|
+
import "./EFPreview.js";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
11
|
+
const key = localStorage.key(i);
|
|
12
|
+
if (typeof key !== "string") continue;
|
|
13
|
+
localStorage.removeItem(key);
|
|
14
|
+
}
|
|
15
|
+
while (document.body.children.length) {
|
|
16
|
+
document.body.children[0]?.remove();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const renderTimeDisplay = (result: TemplateResult) => {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
litRender(result, container);
|
|
23
|
+
const timeDisplay = container.querySelector("ef-time-display");
|
|
24
|
+
if (!timeDisplay) {
|
|
25
|
+
throw new Error("No ef-time-display found");
|
|
26
|
+
}
|
|
27
|
+
if (!(timeDisplay instanceof EFTimeDisplay)) {
|
|
28
|
+
throw new Error("Element is not an EFTimeDisplay");
|
|
29
|
+
}
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
return { timeDisplay, container };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("EFTimeDisplay", () => {
|
|
35
|
+
test("shows 0:00 / 0:00 when context is null", async () => {
|
|
36
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
37
|
+
html`<ef-time-display></ef-time-display>`,
|
|
38
|
+
);
|
|
39
|
+
await timeDisplay.updateComplete;
|
|
40
|
+
|
|
41
|
+
const timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
42
|
+
assert.equal(timeText, "0:00 / 0:00");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("works correctly with real EFTimegroup context", async () => {
|
|
46
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
47
|
+
html`<ef-preview>
|
|
48
|
+
<ef-timegroup mode="fixed" duration="5s">
|
|
49
|
+
<ef-time-display></ef-time-display>
|
|
50
|
+
</ef-timegroup>
|
|
51
|
+
</ef-preview>`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await timeDisplay.updateComplete;
|
|
55
|
+
|
|
56
|
+
const timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
57
|
+
assert.equal(
|
|
58
|
+
timeText,
|
|
59
|
+
"0:00 / 0:05",
|
|
60
|
+
"Should work with real timegroup context",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("handles undefined currentTimeMs gracefully", async () => {
|
|
65
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
66
|
+
html`<ef-preview>
|
|
67
|
+
<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
|
|
68
|
+
<ef-time-display></ef-time-display>
|
|
69
|
+
</ef-preview>`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// const context = timeDisplay.closest("ef-preview") ;
|
|
73
|
+
await timeDisplay.updateComplete;
|
|
74
|
+
|
|
75
|
+
const timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
76
|
+
assert.equal(
|
|
77
|
+
timeText,
|
|
78
|
+
"0:00 / 0:05",
|
|
79
|
+
"Should show 0:00 when currentTimeMs is undefined",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles NaN currentTimeMs gracefully", async () => {
|
|
84
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
85
|
+
html`<ef-preview>
|
|
86
|
+
<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
|
|
87
|
+
<ef-time-display></ef-time-display>
|
|
88
|
+
</ef-preview>`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
92
|
+
context.currentTimeMs = Number.NaN;
|
|
93
|
+
await timeDisplay.updateComplete;
|
|
94
|
+
|
|
95
|
+
const timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
96
|
+
assert.equal(
|
|
97
|
+
timeText,
|
|
98
|
+
"0:00 / 0:05",
|
|
99
|
+
"Should show 0:00 when currentTimeMs is NaN",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("formats time correctly with valid values", async () => {
|
|
104
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
105
|
+
html`<ef-preview>
|
|
106
|
+
<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
|
|
107
|
+
<ef-time-display></ef-time-display>
|
|
108
|
+
</ef-preview>`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
112
|
+
|
|
113
|
+
// Create a mock timegroup with duration
|
|
114
|
+
const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
|
|
115
|
+
mockTimegroup.setAttribute("mode", "fixed");
|
|
116
|
+
mockTimegroup.setAttribute("duration", "3s");
|
|
117
|
+
|
|
118
|
+
context.targetTimegroup = mockTimegroup;
|
|
119
|
+
context.currentTimeMs = 1500; // 1.5 seconds = 0:01
|
|
120
|
+
await timeDisplay.updateComplete;
|
|
121
|
+
|
|
122
|
+
await vi.waitUntil(
|
|
123
|
+
() => timeDisplay.shadowRoot?.textContent?.trim() === "0:01 / 0:03",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("formats minutes correctly", async () => {
|
|
128
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
129
|
+
html`<ef-preview>
|
|
130
|
+
<ef-timegroup mode="fixed" duration="125s"></ef-timegroup>
|
|
131
|
+
<ef-time-display></ef-time-display>
|
|
132
|
+
</ef-preview>`,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
136
|
+
|
|
137
|
+
// Create a mock timegroup with longer duration
|
|
138
|
+
const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
|
|
139
|
+
mockTimegroup.setAttribute("mode", "fixed");
|
|
140
|
+
mockTimegroup.setAttribute("duration", "125s"); // 2:05
|
|
141
|
+
|
|
142
|
+
context.currentTimeMs = 75000; // 75 seconds = 1:15
|
|
143
|
+
await vi.waitUntil(
|
|
144
|
+
() => timeDisplay.shadowRoot?.textContent?.trim() === "1:15 / 2:05",
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("updates display when time changes", async () => {
|
|
149
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
150
|
+
html`<ef-preview>
|
|
151
|
+
<ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
|
|
152
|
+
<ef-time-display></ef-time-display>
|
|
153
|
+
</ef-preview>`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
157
|
+
|
|
158
|
+
const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
|
|
159
|
+
mockTimegroup.setAttribute("mode", "fixed");
|
|
160
|
+
mockTimegroup.setAttribute("duration", "10s");
|
|
161
|
+
|
|
162
|
+
context.targetTimegroup = mockTimegroup;
|
|
163
|
+
|
|
164
|
+
// Initial time
|
|
165
|
+
context.currentTimeMs = 2000; // 2 seconds = 0:02
|
|
166
|
+
await timeDisplay.updateComplete;
|
|
167
|
+
|
|
168
|
+
let timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
169
|
+
assert.equal(timeText, "0:02 / 0:10");
|
|
170
|
+
|
|
171
|
+
// Update time
|
|
172
|
+
context.currentTimeMs = 7000; // 7 seconds = 0:07
|
|
173
|
+
await timeDisplay.updateComplete;
|
|
174
|
+
|
|
175
|
+
timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
176
|
+
assert.equal(timeText, "0:07 / 0:10", "Should update when time changes");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("handles zero duration", async () => {
|
|
180
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
181
|
+
html`<ef-preview>
|
|
182
|
+
<ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
|
|
183
|
+
<ef-time-display></ef-time-display>
|
|
184
|
+
</ef-preview>`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
188
|
+
const timegroup = context.targetTimegroup!;
|
|
189
|
+
|
|
190
|
+
timegroup.setAttribute("duration", "0s");
|
|
191
|
+
assert.equal(timegroup.durationMs, 0);
|
|
192
|
+
|
|
193
|
+
await timegroup.updateComplete;
|
|
194
|
+
await timeDisplay.updateComplete;
|
|
195
|
+
await vi.waitUntil(
|
|
196
|
+
() => timeDisplay.shadowRoot?.textContent?.trim() === "0:00 / 0:00",
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("handles context changes correctly", async () => {
|
|
201
|
+
const { timeDisplay } = renderTimeDisplay(
|
|
202
|
+
html`<ef-preview>
|
|
203
|
+
<ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
|
|
204
|
+
<ef-time-display></ef-time-display>
|
|
205
|
+
</ef-preview>`,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const context = timeDisplay.closest("ef-preview")!;
|
|
209
|
+
|
|
210
|
+
// Set initial values
|
|
211
|
+
context.currentTimeMs = 1000;
|
|
212
|
+
await timeDisplay.updateComplete;
|
|
213
|
+
|
|
214
|
+
let timeText = timeDisplay.shadowRoot?.textContent?.trim();
|
|
215
|
+
assert.equal(timeText, "0:01 / 0:10");
|
|
216
|
+
|
|
217
|
+
// Move to a different context (using renderTimeDisplay to get proper TestContext)
|
|
218
|
+
const { timeDisplay: newTimeDisplay, container: newContainer } =
|
|
219
|
+
renderTimeDisplay(
|
|
220
|
+
html`<ef-preview>
|
|
221
|
+
<ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
|
|
222
|
+
<ef-time-display></ef-time-display>
|
|
223
|
+
</ef-preview>`,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const newContext = newTimeDisplay.closest("ef-preview")!;
|
|
227
|
+
|
|
228
|
+
// Set different values in new context
|
|
229
|
+
newContext.currentTimeMs = 3000;
|
|
230
|
+
await newTimeDisplay.updateComplete;
|
|
231
|
+
|
|
232
|
+
timeText = newTimeDisplay.shadowRoot?.textContent?.trim();
|
|
233
|
+
assert.equal(timeText, "0:03 / 0:10", "Should work with new context");
|
|
234
|
+
|
|
235
|
+
newContainer.remove();
|
|
236
|
+
});
|
|
237
|
+
});
|
package/src/gui/EFTimeDisplay.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { consume } from "@lit/context";
|
|
2
2
|
import { css, html, LitElement } from "lit";
|
|
3
3
|
import { customElement } from "lit/decorators.js";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import { currentTimeContext } from "./currentTimeContext.js";
|
|
5
|
+
import { durationContext } from "./durationContext.js";
|
|
6
6
|
|
|
7
7
|
@customElement("ef-time-display")
|
|
8
8
|
export class EFTimeDisplay extends LitElement {
|
|
@@ -16,46 +16,18 @@ export class EFTimeDisplay extends LitElement {
|
|
|
16
16
|
}
|
|
17
17
|
`;
|
|
18
18
|
|
|
19
|
-
@consume({ context:
|
|
20
|
-
|
|
19
|
+
@consume({ context: currentTimeContext, subscribe: true })
|
|
20
|
+
currentTimeMs = Number.NaN;
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
22
|
+
@consume({ context: durationContext, subscribe: true })
|
|
23
|
+
durationMs = 0;
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
this._onTimeUpdate as EventListener,
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
protected updated(changedProperties: Map<PropertyKey, unknown>): void {
|
|
35
|
-
if (changedProperties.has("context")) {
|
|
36
|
-
// Clean up old listener
|
|
37
|
-
const oldContext = changedProperties.get(
|
|
38
|
-
"context",
|
|
39
|
-
) as ContextMixinInterface | null;
|
|
40
|
-
oldContext?.removeEventListener(
|
|
41
|
-
"timeupdate",
|
|
42
|
-
this._onTimeUpdate as EventListener,
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
// Add new listener
|
|
46
|
-
this.context?.addEventListener(
|
|
47
|
-
"timeupdate",
|
|
48
|
-
this._onTimeUpdate as EventListener,
|
|
49
|
-
);
|
|
25
|
+
private formatTime(ms: number): string {
|
|
26
|
+
// Handle NaN, undefined, null, or negative values
|
|
27
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
28
|
+
return "0:00";
|
|
50
29
|
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
disconnectedCallback(): void {
|
|
54
|
-
this.context?.removeEventListener("timeupdate", this._onTimeUpdate);
|
|
55
|
-
super.disconnectedCallback();
|
|
56
|
-
}
|
|
57
30
|
|
|
58
|
-
private formatTime(ms: number): string {
|
|
59
31
|
const totalSeconds = Math.floor(ms / 1000);
|
|
60
32
|
const minutes = Math.floor(totalSeconds / 60);
|
|
61
33
|
const seconds = totalSeconds % 60;
|
|
@@ -63,8 +35,8 @@ export class EFTimeDisplay extends LitElement {
|
|
|
63
35
|
}
|
|
64
36
|
|
|
65
37
|
render() {
|
|
66
|
-
const currentTime = this.
|
|
67
|
-
const totalTime = this.
|
|
38
|
+
const currentTime = this.currentTimeMs;
|
|
39
|
+
const totalTime = this.durationMs;
|
|
68
40
|
|
|
69
41
|
return html`
|
|
70
42
|
<span part="time">
|
|
@@ -274,6 +274,19 @@ export interface MediaEngine {
|
|
|
274
274
|
maxVideoBufferFetches: number;
|
|
275
275
|
maxAudioBufferFetches: number;
|
|
276
276
|
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Extract thumbnail canvases at multiple timestamps efficiently
|
|
280
|
+
* Uses scrub rendition and batches by segment for optimal performance
|
|
281
|
+
* Returns thumbnail objects in same order as input timestamps
|
|
282
|
+
* Returns null for any timestamps that fail to extract
|
|
283
|
+
*/
|
|
284
|
+
extractThumbnails(timestamps: number[]): Promise<(ThumbnailResult | null)[]>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface ThumbnailResult {
|
|
288
|
+
timestamp: number;
|
|
289
|
+
thumbnail: HTMLCanvasElement | OffscreenCanvas;
|
|
277
290
|
}
|
|
278
291
|
interface InitSegmentPath {
|
|
279
292
|
path: string;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { LRUCache, OrderedLRUCache } from "./LRUCache";
|
|
3
|
+
|
|
4
|
+
describe("LRUCache", () => {
|
|
5
|
+
test("basic LRU functionality", () => {
|
|
6
|
+
const cache = new LRUCache<string, number>(3);
|
|
7
|
+
|
|
8
|
+
cache.set("a", 1);
|
|
9
|
+
cache.set("b", 2);
|
|
10
|
+
cache.set("c", 3);
|
|
11
|
+
|
|
12
|
+
expect(cache.get("a")).toBe(1);
|
|
13
|
+
expect(cache.get("b")).toBe(2);
|
|
14
|
+
expect(cache.get("c")).toBe(3);
|
|
15
|
+
expect(cache.size).toBe(3);
|
|
16
|
+
|
|
17
|
+
// Should evict oldest when adding fourth item
|
|
18
|
+
cache.set("d", 4);
|
|
19
|
+
expect(cache.size).toBe(3);
|
|
20
|
+
expect(cache.has("a")).toBe(false); // 'a' was least recently used
|
|
21
|
+
expect(cache.get("d")).toBe(4);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("OrderedLRUCache", () => {
|
|
26
|
+
test("basic LRU functionality with ordered keys", () => {
|
|
27
|
+
const cache = new OrderedLRUCache<number, string>(3);
|
|
28
|
+
|
|
29
|
+
cache.set(10, "ten");
|
|
30
|
+
cache.set(5, "five");
|
|
31
|
+
cache.set(15, "fifteen");
|
|
32
|
+
|
|
33
|
+
expect(cache.get(10)).toBe("ten");
|
|
34
|
+
expect(cache.get(5)).toBe("five");
|
|
35
|
+
expect(cache.get(15)).toBe("fifteen");
|
|
36
|
+
expect(cache.size).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("maintains sorted key order", () => {
|
|
40
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
41
|
+
|
|
42
|
+
cache.set(30, "thirty");
|
|
43
|
+
cache.set(10, "ten");
|
|
44
|
+
cache.set(20, "twenty");
|
|
45
|
+
cache.set(40, "forty");
|
|
46
|
+
cache.set(15, "fifteen");
|
|
47
|
+
|
|
48
|
+
const sortedKeys = cache.getSortedKeys();
|
|
49
|
+
expect(sortedKeys).toEqual([10, 15, 20, 30, 40]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("binary search exact match", () => {
|
|
53
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
54
|
+
|
|
55
|
+
cache.set(10, "ten");
|
|
56
|
+
cache.set(20, "twenty");
|
|
57
|
+
cache.set(30, "thirty");
|
|
58
|
+
|
|
59
|
+
expect(cache.findExact(20)).toBe("twenty");
|
|
60
|
+
expect(cache.findExact(25)).toBe(undefined);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("find nearest in range", () => {
|
|
64
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
65
|
+
|
|
66
|
+
cache.set(10, "ten");
|
|
67
|
+
cache.set(20, "twenty");
|
|
68
|
+
cache.set(30, "thirty");
|
|
69
|
+
cache.set(40, "forty");
|
|
70
|
+
|
|
71
|
+
// Test various range scenarios
|
|
72
|
+
const range12_5 = cache.findNearestInRange(12, 5);
|
|
73
|
+
expect(range12_5.map((item) => item.key)).toEqual([10]); // 12±5 = [7,17] contains only 10
|
|
74
|
+
|
|
75
|
+
const range25_8 = cache.findNearestInRange(25, 8);
|
|
76
|
+
expect(range25_8.map((item) => item.key)).toEqual([20, 30]); // 25±8 = [17,33] contains 20,30
|
|
77
|
+
|
|
78
|
+
const range35_3 = cache.findNearestInRange(35, 3);
|
|
79
|
+
expect(range35_3.map((item) => item.key)).toEqual([]); // 35±3 = [32,38] contains nothing
|
|
80
|
+
|
|
81
|
+
const range50_15 = cache.findNearestInRange(50, 15);
|
|
82
|
+
expect(range50_15.map((item) => item.key)).toEqual([40]); // 50±15 = [35,65] contains only 40
|
|
83
|
+
|
|
84
|
+
const range25_15 = cache.findNearestInRange(25, 15);
|
|
85
|
+
expect(range25_15.map((item) => item.key)).toEqual([10, 20, 30, 40]); // 25±15 = [10,40] contains all
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("range search", () => {
|
|
89
|
+
const cache = new OrderedLRUCache<number, string>(10);
|
|
90
|
+
|
|
91
|
+
cache.set(10, "ten");
|
|
92
|
+
cache.set(15, "fifteen");
|
|
93
|
+
cache.set(20, "twenty");
|
|
94
|
+
cache.set(25, "twenty-five");
|
|
95
|
+
cache.set(30, "thirty");
|
|
96
|
+
cache.set(35, "thirty-five");
|
|
97
|
+
|
|
98
|
+
const range1 = cache.findRange(15, 30);
|
|
99
|
+
expect(range1.map((item) => item.key)).toEqual([15, 20, 25, 30]);
|
|
100
|
+
|
|
101
|
+
const range2 = cache.findRange(12, 23);
|
|
102
|
+
expect(range2.map((item) => item.key)).toEqual([15, 20]);
|
|
103
|
+
|
|
104
|
+
const range3 = cache.findRange(40, 50);
|
|
105
|
+
expect(range3).toEqual([]); // No keys in range
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("LRU eviction maintains sorted order", () => {
|
|
109
|
+
const cache = new OrderedLRUCache<number, string>(3);
|
|
110
|
+
|
|
111
|
+
cache.set(30, "thirty");
|
|
112
|
+
cache.set(10, "ten");
|
|
113
|
+
cache.set(20, "twenty");
|
|
114
|
+
|
|
115
|
+
// Access 10 to make it most recent
|
|
116
|
+
cache.get(10);
|
|
117
|
+
|
|
118
|
+
// Add new item, should evict 30 (least recently used)
|
|
119
|
+
cache.set(40, "forty");
|
|
120
|
+
|
|
121
|
+
expect(cache.has(30)).toBe(false);
|
|
122
|
+
expect(cache.getSortedKeys()).toEqual([10, 20, 40]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("works with string keys and custom comparator", () => {
|
|
126
|
+
const cache = new OrderedLRUCache<string, number>(5, (a, b) =>
|
|
127
|
+
a.localeCompare(b),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
cache.set("banana", 2);
|
|
131
|
+
cache.set("apple", 1);
|
|
132
|
+
cache.set("cherry", 3);
|
|
133
|
+
cache.set("date", 4);
|
|
134
|
+
|
|
135
|
+
expect(cache.getSortedKeys()).toEqual([
|
|
136
|
+
"apple",
|
|
137
|
+
"banana",
|
|
138
|
+
"cherry",
|
|
139
|
+
"date",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// For strings, findNearestInRange only works for exact matches since we can't calculate string distance
|
|
143
|
+
const nearestExact = cache.findNearestInRange("cherry", "cherry");
|
|
144
|
+
expect(nearestExact.map((item) => item.key)).toEqual(["cherry"]);
|
|
145
|
+
|
|
146
|
+
// But range searches work normally
|
|
147
|
+
const range = cache.findRange("banana", "date");
|
|
148
|
+
expect(range.map((item) => item.key)).toEqual(["banana", "cherry", "date"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("handles empty cache gracefully", () => {
|
|
152
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
153
|
+
|
|
154
|
+
expect(cache.findExact(10)).toBe(undefined);
|
|
155
|
+
expect(cache.findNearestInRange(10, 5)).toEqual([]);
|
|
156
|
+
expect(cache.findRange(1, 10)).toEqual([]);
|
|
157
|
+
expect(cache.getSortedKeys()).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("handles single item cache", () => {
|
|
161
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
162
|
+
cache.set(10, "ten");
|
|
163
|
+
|
|
164
|
+
expect(cache.findNearestInRange(5, 10)).toEqual([
|
|
165
|
+
{ key: 10, value: "ten" },
|
|
166
|
+
]); // 5±10 = [-5,15] contains 10
|
|
167
|
+
expect(cache.findNearestInRange(15, 10)).toEqual([
|
|
168
|
+
{ key: 10, value: "ten" },
|
|
169
|
+
]); // 15±10 = [5,25] contains 10
|
|
170
|
+
expect(cache.findRange(1, 20)).toEqual([{ key: 10, value: "ten" }]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("updates existing keys without duplicating in sorted index", () => {
|
|
174
|
+
const cache = new OrderedLRUCache<number, string>(5);
|
|
175
|
+
|
|
176
|
+
cache.set(10, "ten");
|
|
177
|
+
cache.set(20, "twenty");
|
|
178
|
+
cache.set(10, "TEN"); // Update existing key
|
|
179
|
+
|
|
180
|
+
expect(cache.getSortedKeys()).toEqual([10, 20]); // No duplicates
|
|
181
|
+
expect(cache.get(10)).toBe("TEN");
|
|
182
|
+
expect(cache.size).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Example use case: video segment caching by timestamp
|
|
186
|
+
test("video segment caching use case", () => {
|
|
187
|
+
interface VideoSegment {
|
|
188
|
+
url: string;
|
|
189
|
+
duration: number;
|
|
190
|
+
bitrate: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const segmentCache = new OrderedLRUCache<number, VideoSegment>(10);
|
|
194
|
+
|
|
195
|
+
// Add segments with timestamps as keys
|
|
196
|
+
segmentCache.set(0, { url: "/seg0.mp4", duration: 5000, bitrate: 1000 });
|
|
197
|
+
segmentCache.set(5000, { url: "/seg1.mp4", duration: 5000, bitrate: 1000 });
|
|
198
|
+
segmentCache.set(10000, {
|
|
199
|
+
url: "/seg2.mp4",
|
|
200
|
+
duration: 5000,
|
|
201
|
+
bitrate: 1000,
|
|
202
|
+
});
|
|
203
|
+
segmentCache.set(15000, {
|
|
204
|
+
url: "/seg3.mp4",
|
|
205
|
+
duration: 5000,
|
|
206
|
+
bitrate: 1000,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Find segment at specific time
|
|
210
|
+
const segment = segmentCache.findExact(10000);
|
|
211
|
+
expect(segment?.url).toBe("/seg2.mp4");
|
|
212
|
+
|
|
213
|
+
// Find segments near seeking position 7500ms (within 3 seconds)
|
|
214
|
+
const nearSeekPoint = segmentCache.findNearestInRange(7500, 3000);
|
|
215
|
+
expect(nearSeekPoint.map((s) => s.key)).toEqual([5000, 10000]); // 7500±3000 = [4500,10500] contains these
|
|
216
|
+
|
|
217
|
+
// Get all segments in time range 5s-15s
|
|
218
|
+
const range = segmentCache.findRange(5000, 15000);
|
|
219
|
+
expect(range.length).toBe(3);
|
|
220
|
+
expect(range.map((s) => s.key)).toEqual([5000, 10000, 15000]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("findNearestInRange edge cases", () => {
|
|
224
|
+
const cache = new OrderedLRUCache<number, string>(10);
|
|
225
|
+
|
|
226
|
+
cache.set(100, "hundred");
|
|
227
|
+
cache.set(200, "two-hundred");
|
|
228
|
+
cache.set(300, "three-hundred");
|
|
229
|
+
|
|
230
|
+
// Test that it can return empty array when no items in range
|
|
231
|
+
const emptyResult = cache.findNearestInRange(50, 10);
|
|
232
|
+
expect(emptyResult).toEqual([]); // 50±10 = [40,60] contains nothing
|
|
233
|
+
|
|
234
|
+
// Test exact center match
|
|
235
|
+
const exactCenter = cache.findNearestInRange(200, 0);
|
|
236
|
+
expect(exactCenter.map((item) => item.key)).toEqual([200]); // 200±0 = [200,200] exact match
|
|
237
|
+
|
|
238
|
+
// Test asymmetric range (center closer to one side)
|
|
239
|
+
const asymmetric = cache.findNearestInRange(150, 60);
|
|
240
|
+
expect(asymmetric.map((item) => item.key)).toEqual([100, 200]); // 150±60 = [90,210] contains 100,200
|
|
241
|
+
|
|
242
|
+
// Test very large range containing all items
|
|
243
|
+
const allItems = cache.findNearestInRange(200, 500);
|
|
244
|
+
expect(allItems.map((item) => item.key)).toEqual([100, 200, 300]); // 200±500 = [-300,700] contains all
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("performance characteristics with large dataset", () => {
|
|
248
|
+
const cache = new OrderedLRUCache<number, string>(1000);
|
|
249
|
+
|
|
250
|
+
// Insert many items
|
|
251
|
+
const insertStart = performance.now();
|
|
252
|
+
for (let i = 0; i < 1000; i++) {
|
|
253
|
+
cache.set(Math.random() * 10000, `value-${i}`);
|
|
254
|
+
}
|
|
255
|
+
const insertTime = performance.now() - insertStart;
|
|
256
|
+
|
|
257
|
+
// Perform searches
|
|
258
|
+
const searchStart = performance.now();
|
|
259
|
+
for (let i = 0; i < 100; i++) {
|
|
260
|
+
cache.findNearestInRange(Math.random() * 10000, 500);
|
|
261
|
+
cache.findRange(Math.random() * 5000, Math.random() * 5000 + 1000);
|
|
262
|
+
}
|
|
263
|
+
const searchTime = performance.now() - searchStart;
|
|
264
|
+
|
|
265
|
+
// These should complete quickly for O(log n) operations
|
|
266
|
+
expect(insertTime).toBeLessThan(100); // Should be very fast
|
|
267
|
+
expect(searchTime).toBeLessThan(50); // Searches should be even faster
|
|
268
|
+
|
|
269
|
+
expect(cache.size).toBe(1000);
|
|
270
|
+
expect(cache.getSortedKeys().length).toBe(1000);
|
|
271
|
+
});
|
|
272
|
+
});
|