@xiboplayer/renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/PERFORMANCE_OPTIMIZATIONS.md +451 -0
- package/docs/README.md +98 -0
- package/docs/RENDERER_COMPARISON.md +483 -0
- package/docs/TRANSITIONS.md +180 -0
- package/package.json +40 -0
- package/src/index.js +4 -0
- package/src/layout-pool.js +245 -0
- package/src/layout-pool.test.js +373 -0
- package/src/layout.js +1073 -0
- package/src/renderer-lite.js +2637 -0
- package/src/renderer-lite.overlays.test.js +493 -0
- package/src/renderer-lite.test.js +901 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LayoutPool - Maintains a pool of pre-built layout containers
|
|
3
|
+
* for instant layout transitions.
|
|
4
|
+
*
|
|
5
|
+
* Instead of tearing down and rebuilding the DOM on every layout switch,
|
|
6
|
+
* the pool keeps up to `maxSize` layout containers alive. The current
|
|
7
|
+
* layout is marked 'hot' (visible); pre-loaded layouts are 'warm' (hidden).
|
|
8
|
+
* When transitioning, visibility is swapped instantly - no DOM rebuild.
|
|
9
|
+
*
|
|
10
|
+
* Pool entries:
|
|
11
|
+
* layoutId -> { container, layout, regions, blobUrls, mediaUrlCache, status, lastAccess }
|
|
12
|
+
*
|
|
13
|
+
* Status: 'hot' (currently visible) or 'warm' (preloaded, hidden)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
17
|
+
|
|
18
|
+
const log = createLogger('LayoutPool');
|
|
19
|
+
|
|
20
|
+
export class LayoutPool {
|
|
21
|
+
/**
|
|
22
|
+
* @param {number} maxSize - Maximum number of layouts to keep in pool (default: 2)
|
|
23
|
+
*/
|
|
24
|
+
constructor(maxSize = 2) {
|
|
25
|
+
/** @type {Map<number, Object>} */
|
|
26
|
+
this.layouts = new Map();
|
|
27
|
+
this.maxSize = maxSize;
|
|
28
|
+
/** @type {number|null} */
|
|
29
|
+
this.hotLayoutId = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a layout is in the pool
|
|
34
|
+
* @param {number} layoutId
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
has(layoutId) {
|
|
38
|
+
return this.layouts.has(layoutId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get a pool entry
|
|
43
|
+
* @param {number} layoutId
|
|
44
|
+
* @returns {Object|undefined}
|
|
45
|
+
*/
|
|
46
|
+
get(layoutId) {
|
|
47
|
+
return this.layouts.get(layoutId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Add a layout entry to the pool.
|
|
52
|
+
* If pool is full, evicts the least-recently-used warm entry.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} layoutId
|
|
55
|
+
* @param {Object} entry - Pool entry
|
|
56
|
+
* @param {HTMLElement} entry.container - Layout container DOM element
|
|
57
|
+
* @param {Object} entry.layout - Parsed layout object
|
|
58
|
+
* @param {Map} entry.regions - Region map (regionId => region state)
|
|
59
|
+
* @param {Set<string>} entry.blobUrls - Tracked blob URLs for this layout
|
|
60
|
+
* @param {Map} [entry.mediaUrlCache] - Media URL cache (fileId => url)
|
|
61
|
+
*/
|
|
62
|
+
add(layoutId, entry) {
|
|
63
|
+
// If already in pool, update in place
|
|
64
|
+
if (this.layouts.has(layoutId)) {
|
|
65
|
+
const existing = this.layouts.get(layoutId);
|
|
66
|
+
Object.assign(existing, entry);
|
|
67
|
+
existing.lastAccess = Date.now();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If pool is full, evict LRU warm entry
|
|
72
|
+
if (this.layouts.size >= this.maxSize) {
|
|
73
|
+
this.evictLRU();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
entry.status = 'warm';
|
|
77
|
+
entry.lastAccess = Date.now();
|
|
78
|
+
this.layouts.set(layoutId, entry);
|
|
79
|
+
log.info(`Added layout ${layoutId} to pool (size: ${this.layouts.size}/${this.maxSize})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark a layout as active (visible).
|
|
84
|
+
* The previous hot layout is demoted to warm.
|
|
85
|
+
* @param {number} layoutId
|
|
86
|
+
*/
|
|
87
|
+
setHot(layoutId) {
|
|
88
|
+
// Demote previous hot layout to warm
|
|
89
|
+
if (this.hotLayoutId !== null && this.layouts.has(this.hotLayoutId)) {
|
|
90
|
+
this.layouts.get(this.hotLayoutId).status = 'warm';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.layouts.has(layoutId)) {
|
|
94
|
+
const entry = this.layouts.get(layoutId);
|
|
95
|
+
entry.status = 'hot';
|
|
96
|
+
entry.lastAccess = Date.now();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.hotLayoutId = layoutId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Evict a specific layout from the pool.
|
|
104
|
+
* Revokes blob URLs and removes the container from the DOM.
|
|
105
|
+
* @param {number} layoutId
|
|
106
|
+
*/
|
|
107
|
+
evict(layoutId) {
|
|
108
|
+
const entry = this.layouts.get(layoutId);
|
|
109
|
+
if (!entry) return;
|
|
110
|
+
|
|
111
|
+
log.info(`Evicting layout ${layoutId} from pool`);
|
|
112
|
+
|
|
113
|
+
// Revoke blob URLs
|
|
114
|
+
if (entry.blobUrls && entry.blobUrls.size > 0) {
|
|
115
|
+
entry.blobUrls.forEach(url => {
|
|
116
|
+
URL.revokeObjectURL(url);
|
|
117
|
+
});
|
|
118
|
+
log.info(`Revoked ${entry.blobUrls.size} blob URLs for layout ${layoutId}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Revoke media URL cache blob URLs
|
|
122
|
+
if (entry.mediaUrlCache) {
|
|
123
|
+
for (const [fileId, blobUrl] of entry.mediaUrlCache) {
|
|
124
|
+
if (blobUrl && typeof blobUrl === 'string' && blobUrl.startsWith('blob:')) {
|
|
125
|
+
URL.revokeObjectURL(blobUrl);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Stop any active region timers
|
|
131
|
+
if (entry.regions) {
|
|
132
|
+
for (const [regionId, region] of entry.regions) {
|
|
133
|
+
if (region.timer) {
|
|
134
|
+
clearTimeout(region.timer);
|
|
135
|
+
region.timer = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remove container from DOM
|
|
141
|
+
if (entry.container && entry.container.parentNode) {
|
|
142
|
+
entry.container.remove();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.layouts.delete(layoutId);
|
|
146
|
+
|
|
147
|
+
// Clear hot reference if this was the hot layout
|
|
148
|
+
if (this.hotLayoutId === layoutId) {
|
|
149
|
+
this.hotLayoutId = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Evict the least-recently-used warm entry.
|
|
155
|
+
* Only warm entries are eligible for eviction (never the hot layout).
|
|
156
|
+
*/
|
|
157
|
+
evictLRU() {
|
|
158
|
+
let oldest = null;
|
|
159
|
+
let oldestTime = Infinity;
|
|
160
|
+
|
|
161
|
+
for (const [id, entry] of this.layouts) {
|
|
162
|
+
if (entry.status === 'warm' && entry.lastAccess < oldestTime) {
|
|
163
|
+
oldest = id;
|
|
164
|
+
oldestTime = entry.lastAccess;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (oldest !== null) {
|
|
169
|
+
this.evict(oldest);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Clear all warm (preloaded) entries, keeping the hot layout.
|
|
175
|
+
* @returns {number} Number of entries cleared
|
|
176
|
+
*/
|
|
177
|
+
clearWarm() {
|
|
178
|
+
let count = 0;
|
|
179
|
+
const warmIds = [];
|
|
180
|
+
|
|
181
|
+
for (const [id, entry] of this.layouts) {
|
|
182
|
+
if (entry.status === 'warm') {
|
|
183
|
+
warmIds.push(id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const id of warmIds) {
|
|
188
|
+
this.evict(id);
|
|
189
|
+
count++;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (count > 0) {
|
|
193
|
+
log.info(`Cleared ${count} warm layout(s) from pool`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return count;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Clear warm entries NOT in the given set of layout IDs.
|
|
201
|
+
* Keeps warm entries that are still scheduled.
|
|
202
|
+
* @param {Set<number>} keepIds - Layout IDs to keep
|
|
203
|
+
* @returns {number} Number of entries cleared
|
|
204
|
+
*/
|
|
205
|
+
clearWarmNotIn(keepIds) {
|
|
206
|
+
let count = 0;
|
|
207
|
+
const evictIds = [];
|
|
208
|
+
|
|
209
|
+
for (const [id, entry] of this.layouts) {
|
|
210
|
+
if (entry.status === 'warm' && !keepIds.has(id)) {
|
|
211
|
+
evictIds.push(id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const id of evictIds) {
|
|
216
|
+
this.evict(id);
|
|
217
|
+
count++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (count > 0) {
|
|
221
|
+
log.info(`Cleared ${count} warm layout(s) no longer in schedule`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return count;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Clear all entries (both hot and warm).
|
|
229
|
+
*/
|
|
230
|
+
clear() {
|
|
231
|
+
const ids = Array.from(this.layouts.keys());
|
|
232
|
+
for (const id of ids) {
|
|
233
|
+
this.evict(id);
|
|
234
|
+
}
|
|
235
|
+
this.hotLayoutId = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get the number of entries in the pool.
|
|
240
|
+
* @returns {number}
|
|
241
|
+
*/
|
|
242
|
+
get size() {
|
|
243
|
+
return this.layouts.size;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LayoutPool Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Tests for the layout preload pool: add, get, evict, LRU, clear.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
8
|
+
import { LayoutPool } from './layout-pool.js';
|
|
9
|
+
|
|
10
|
+
describe('LayoutPool', () => {
|
|
11
|
+
let pool;
|
|
12
|
+
|
|
13
|
+
// Mock URL.revokeObjectURL (not available in jsdom)
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
pool = new LayoutPool(2);
|
|
16
|
+
|
|
17
|
+
if (!global.URL.revokeObjectURL) {
|
|
18
|
+
global.URL.revokeObjectURL = vi.fn();
|
|
19
|
+
} else {
|
|
20
|
+
vi.spyOn(URL, 'revokeObjectURL');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Helper: create a mock pool entry
|
|
26
|
+
*/
|
|
27
|
+
function createMockEntry(layoutId) {
|
|
28
|
+
const container = document.createElement('div');
|
|
29
|
+
container.id = `layout_${layoutId}`;
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
container,
|
|
34
|
+
layout: { width: 1920, height: 1080, duration: 60, bgcolor: '#000', regions: [] },
|
|
35
|
+
regions: new Map(),
|
|
36
|
+
blobUrls: new Set(),
|
|
37
|
+
mediaUrlCache: new Map()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('constructor', () => {
|
|
42
|
+
it('should initialize with default maxSize of 2', () => {
|
|
43
|
+
const defaultPool = new LayoutPool();
|
|
44
|
+
expect(defaultPool.maxSize).toBe(2);
|
|
45
|
+
expect(defaultPool.size).toBe(0);
|
|
46
|
+
expect(defaultPool.hotLayoutId).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should accept custom maxSize', () => {
|
|
50
|
+
const customPool = new LayoutPool(5);
|
|
51
|
+
expect(customPool.maxSize).toBe(5);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('has()', () => {
|
|
56
|
+
it('should return false for empty pool', () => {
|
|
57
|
+
expect(pool.has(1)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return true after adding a layout', () => {
|
|
61
|
+
pool.add(1, createMockEntry(1));
|
|
62
|
+
expect(pool.has(1)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return false for non-existent layout', () => {
|
|
66
|
+
pool.add(1, createMockEntry(1));
|
|
67
|
+
expect(pool.has(2)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('get()', () => {
|
|
72
|
+
it('should return undefined for empty pool', () => {
|
|
73
|
+
expect(pool.get(1)).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return the entry after adding', () => {
|
|
77
|
+
const entry = createMockEntry(1);
|
|
78
|
+
pool.add(1, entry);
|
|
79
|
+
const retrieved = pool.get(1);
|
|
80
|
+
expect(retrieved).toBeTruthy();
|
|
81
|
+
expect(retrieved.layout).toEqual(entry.layout);
|
|
82
|
+
expect(retrieved.status).toBe('warm');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('add()', () => {
|
|
87
|
+
it('should add entry as warm status', () => {
|
|
88
|
+
pool.add(1, createMockEntry(1));
|
|
89
|
+
const entry = pool.get(1);
|
|
90
|
+
expect(entry.status).toBe('warm');
|
|
91
|
+
expect(entry.lastAccess).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should set pool size correctly', () => {
|
|
95
|
+
pool.add(1, createMockEntry(1));
|
|
96
|
+
expect(pool.size).toBe(1);
|
|
97
|
+
|
|
98
|
+
pool.add(2, createMockEntry(2));
|
|
99
|
+
expect(pool.size).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should update in place when adding same layout ID', () => {
|
|
103
|
+
const entry1 = createMockEntry(1);
|
|
104
|
+
entry1.layout.duration = 30;
|
|
105
|
+
pool.add(1, entry1);
|
|
106
|
+
|
|
107
|
+
const entry2 = createMockEntry(1);
|
|
108
|
+
entry2.layout.duration = 60;
|
|
109
|
+
pool.add(1, entry2);
|
|
110
|
+
|
|
111
|
+
expect(pool.size).toBe(1);
|
|
112
|
+
expect(pool.get(1).layout.duration).toBe(60);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should evict LRU warm entry when pool is full', () => {
|
|
116
|
+
pool.add(1, createMockEntry(1));
|
|
117
|
+
pool.add(2, createMockEntry(2));
|
|
118
|
+
|
|
119
|
+
// Pool is full (maxSize=2), adding a third should evict the oldest warm
|
|
120
|
+
pool.add(3, createMockEntry(3));
|
|
121
|
+
|
|
122
|
+
expect(pool.size).toBe(2);
|
|
123
|
+
expect(pool.has(1)).toBe(false); // Oldest evicted
|
|
124
|
+
expect(pool.has(2)).toBe(true);
|
|
125
|
+
expect(pool.has(3)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('setHot()', () => {
|
|
130
|
+
it('should mark entry as hot', () => {
|
|
131
|
+
pool.add(1, createMockEntry(1));
|
|
132
|
+
pool.setHot(1);
|
|
133
|
+
|
|
134
|
+
expect(pool.get(1).status).toBe('hot');
|
|
135
|
+
expect(pool.hotLayoutId).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should demote previous hot entry to warm', () => {
|
|
139
|
+
pool.add(1, createMockEntry(1));
|
|
140
|
+
pool.add(2, createMockEntry(2));
|
|
141
|
+
|
|
142
|
+
pool.setHot(1);
|
|
143
|
+
expect(pool.get(1).status).toBe('hot');
|
|
144
|
+
|
|
145
|
+
pool.setHot(2);
|
|
146
|
+
expect(pool.get(1).status).toBe('warm');
|
|
147
|
+
expect(pool.get(2).status).toBe('hot');
|
|
148
|
+
expect(pool.hotLayoutId).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('evict()', () => {
|
|
153
|
+
it('should remove entry from pool', () => {
|
|
154
|
+
pool.add(1, createMockEntry(1));
|
|
155
|
+
pool.evict(1);
|
|
156
|
+
|
|
157
|
+
expect(pool.has(1)).toBe(false);
|
|
158
|
+
expect(pool.size).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should revoke blob URLs on eviction', () => {
|
|
162
|
+
const entry = createMockEntry(1);
|
|
163
|
+
entry.blobUrls.add('blob:test-1');
|
|
164
|
+
entry.blobUrls.add('blob:test-2');
|
|
165
|
+
|
|
166
|
+
pool.add(1, entry);
|
|
167
|
+
pool.evict(1);
|
|
168
|
+
|
|
169
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-1');
|
|
170
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-2');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should revoke media blob URLs on eviction', () => {
|
|
174
|
+
const entry = createMockEntry(1);
|
|
175
|
+
entry.mediaUrlCache.set(10, 'blob:media-10');
|
|
176
|
+
entry.mediaUrlCache.set(20, 'blob:media-20');
|
|
177
|
+
|
|
178
|
+
pool.add(1, entry);
|
|
179
|
+
pool.evict(1);
|
|
180
|
+
|
|
181
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media-10');
|
|
182
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:media-20');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should remove container from DOM', () => {
|
|
186
|
+
const entry = createMockEntry(1);
|
|
187
|
+
pool.add(1, entry);
|
|
188
|
+
|
|
189
|
+
expect(entry.container.parentNode).toBeTruthy();
|
|
190
|
+
pool.evict(1);
|
|
191
|
+
expect(entry.container.parentNode).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should clear hot reference if evicting hot layout', () => {
|
|
195
|
+
pool.add(1, createMockEntry(1));
|
|
196
|
+
pool.setHot(1);
|
|
197
|
+
expect(pool.hotLayoutId).toBe(1);
|
|
198
|
+
|
|
199
|
+
pool.evict(1);
|
|
200
|
+
expect(pool.hotLayoutId).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should do nothing for non-existent layout', () => {
|
|
204
|
+
// Should not throw
|
|
205
|
+
pool.evict(999);
|
|
206
|
+
expect(pool.size).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should clear region timers on eviction', () => {
|
|
210
|
+
const entry = createMockEntry(1);
|
|
211
|
+
const mockTimer = setTimeout(() => {}, 99999);
|
|
212
|
+
const clearSpy = vi.spyOn(global, 'clearTimeout');
|
|
213
|
+
|
|
214
|
+
entry.regions.set('r1', {
|
|
215
|
+
timer: mockTimer,
|
|
216
|
+
element: document.createElement('div'),
|
|
217
|
+
widgets: [],
|
|
218
|
+
widgetElements: new Map()
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
pool.add(1, entry);
|
|
222
|
+
pool.evict(1);
|
|
223
|
+
|
|
224
|
+
expect(clearSpy).toHaveBeenCalledWith(mockTimer);
|
|
225
|
+
clearSpy.mockRestore();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('evictLRU()', () => {
|
|
230
|
+
it('should evict oldest warm entry', () => {
|
|
231
|
+
const entry1 = createMockEntry(1);
|
|
232
|
+
const entry2 = createMockEntry(2);
|
|
233
|
+
|
|
234
|
+
// Use deterministic timestamps to ensure entry1 is older
|
|
235
|
+
let time = 1000;
|
|
236
|
+
vi.spyOn(Date, 'now').mockImplementation(() => time);
|
|
237
|
+
|
|
238
|
+
pool.add(1, entry1);
|
|
239
|
+
time = 2000;
|
|
240
|
+
pool.add(2, entry2);
|
|
241
|
+
|
|
242
|
+
// entry1 has lastAccess=1000, entry2 has lastAccess=2000
|
|
243
|
+
// evictLRU should evict entry1 (oldest warm)
|
|
244
|
+
pool.evictLRU();
|
|
245
|
+
|
|
246
|
+
expect(pool.has(1)).toBe(false);
|
|
247
|
+
expect(pool.has(2)).toBe(true);
|
|
248
|
+
|
|
249
|
+
vi.restoreAllMocks();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should not evict hot entry', () => {
|
|
253
|
+
pool.add(1, createMockEntry(1));
|
|
254
|
+
pool.add(2, createMockEntry(2));
|
|
255
|
+
pool.setHot(1);
|
|
256
|
+
|
|
257
|
+
// Only warm entries can be evicted
|
|
258
|
+
pool.evictLRU();
|
|
259
|
+
|
|
260
|
+
expect(pool.has(1)).toBe(true); // Hot - preserved
|
|
261
|
+
expect(pool.has(2)).toBe(false); // Warm - evicted
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should do nothing if no warm entries', () => {
|
|
265
|
+
pool.add(1, createMockEntry(1));
|
|
266
|
+
pool.setHot(1);
|
|
267
|
+
|
|
268
|
+
// Only hot entry exists - nothing to evict
|
|
269
|
+
pool.evictLRU();
|
|
270
|
+
expect(pool.size).toBe(1);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('clearWarm()', () => {
|
|
275
|
+
it('should clear all warm entries', () => {
|
|
276
|
+
pool.add(1, createMockEntry(1));
|
|
277
|
+
pool.add(2, createMockEntry(2));
|
|
278
|
+
pool.setHot(1);
|
|
279
|
+
|
|
280
|
+
const count = pool.clearWarm();
|
|
281
|
+
|
|
282
|
+
expect(count).toBe(1);
|
|
283
|
+
expect(pool.has(1)).toBe(true); // Hot - preserved
|
|
284
|
+
expect(pool.has(2)).toBe(false); // Warm - cleared
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should return 0 when no warm entries', () => {
|
|
288
|
+
pool.add(1, createMockEntry(1));
|
|
289
|
+
pool.setHot(1);
|
|
290
|
+
|
|
291
|
+
const count = pool.clearWarm();
|
|
292
|
+
expect(count).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should clear all entries when all are warm', () => {
|
|
296
|
+
pool.add(1, createMockEntry(1));
|
|
297
|
+
pool.add(2, createMockEntry(2));
|
|
298
|
+
|
|
299
|
+
const count = pool.clearWarm();
|
|
300
|
+
|
|
301
|
+
expect(count).toBe(2);
|
|
302
|
+
expect(pool.size).toBe(0);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('clear()', () => {
|
|
307
|
+
it('should clear all entries including hot', () => {
|
|
308
|
+
pool.add(1, createMockEntry(1));
|
|
309
|
+
pool.add(2, createMockEntry(2));
|
|
310
|
+
pool.setHot(1);
|
|
311
|
+
|
|
312
|
+
pool.clear();
|
|
313
|
+
|
|
314
|
+
expect(pool.size).toBe(0);
|
|
315
|
+
expect(pool.hotLayoutId).toBeNull();
|
|
316
|
+
expect(pool.has(1)).toBe(false);
|
|
317
|
+
expect(pool.has(2)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should work on empty pool', () => {
|
|
321
|
+
pool.clear();
|
|
322
|
+
expect(pool.size).toBe(0);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('size', () => {
|
|
327
|
+
it('should track pool size accurately', () => {
|
|
328
|
+
expect(pool.size).toBe(0);
|
|
329
|
+
|
|
330
|
+
pool.add(1, createMockEntry(1));
|
|
331
|
+
expect(pool.size).toBe(1);
|
|
332
|
+
|
|
333
|
+
pool.add(2, createMockEntry(2));
|
|
334
|
+
expect(pool.size).toBe(2);
|
|
335
|
+
|
|
336
|
+
pool.evict(1);
|
|
337
|
+
expect(pool.size).toBe(1);
|
|
338
|
+
|
|
339
|
+
pool.clear();
|
|
340
|
+
expect(pool.size).toBe(0);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('LRU eviction under pressure', () => {
|
|
345
|
+
it('should evict oldest warm when adding beyond maxSize', () => {
|
|
346
|
+
// maxSize=2, add 3 layouts
|
|
347
|
+
pool.add(1, createMockEntry(1));
|
|
348
|
+
pool.add(2, createMockEntry(2));
|
|
349
|
+
|
|
350
|
+
// Add third - should evict layout 1 (oldest)
|
|
351
|
+
pool.add(3, createMockEntry(3));
|
|
352
|
+
|
|
353
|
+
expect(pool.size).toBe(2);
|
|
354
|
+
expect(pool.has(1)).toBe(false);
|
|
355
|
+
expect(pool.has(2)).toBe(true);
|
|
356
|
+
expect(pool.has(3)).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should preserve hot layout during LRU eviction', () => {
|
|
360
|
+
pool.add(1, createMockEntry(1));
|
|
361
|
+
pool.setHot(1);
|
|
362
|
+
pool.add(2, createMockEntry(2));
|
|
363
|
+
|
|
364
|
+
// Pool is full (1=hot, 2=warm). Adding 3 should evict 2 (warm), not 1 (hot)
|
|
365
|
+
pool.add(3, createMockEntry(3));
|
|
366
|
+
|
|
367
|
+
expect(pool.size).toBe(2);
|
|
368
|
+
expect(pool.has(1)).toBe(true); // Hot - preserved
|
|
369
|
+
expect(pool.has(2)).toBe(false); // Warm - evicted
|
|
370
|
+
expect(pool.has(3)).toBe(true); // Newly added
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|