Files
hyperframes/packages/engine/src/services/videoFrameInjector.test.ts
T

146 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { __testing } from "./videoFrameInjector.js";
import { DEFAULT_CONFIG } from "../config.js";
const { createFrameSourceCache } = __testing;
const SHARED_STATS = { evictions: 0, oversizedRejections: 0 };
describe("frame source cache eviction", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "hf-frame-cache-test-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Each PNG is base64-encoded into the data URI, so the cached string is
// ~4/3 the file size plus a small `data:image/png;base64,` prefix. Build
// distinct files so eviction has predictable victims.
function writeFrame(name: string, sizeBytes: number): string {
const filePath = join(dir, name);
writeFileSync(filePath, Buffer.alloc(sizeBytes, 0));
return filePath;
}
it("evicts oldest entry when entry count exceeds limit", async () => {
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
const a = writeFrame("a.png", 16);
const b = writeFrame("b.png", 16);
const c = writeFrame("c.png", 16);
await cache.get(a);
await cache.get(b);
expect(cache.stats().entries).toBe(2);
await cache.get(c);
expect(cache.stats().entries).toBe(2);
expect(cache.stats().evictions).toBe(1);
// Verify the *oldest* entry (a) was the victim — the LRU contract.
// A later get(a) is a miss-then-insert, which would also evict whichever
// entry is now oldest. We instrument the eviction counter to detect it.
const evictionsBefore = cache.stats().evictions;
await cache.get(a);
expect(cache.stats().evictions).toBe(evictionsBefore + 1);
// After re-inserting `a`, `b` is the next oldest. `c` is now newest.
// Touch `b` (move-to-front) → next eviction would be `c`, not `b`.
});
it("evicts oldest entry when byte budget is exceeded", async () => {
// 1 KB raw frame → ~1.4 KB base64 + ~22-byte data URI prefix. Pick a
// budget that comfortably fits two URIs but not three, so the third
// get() forces eviction even though the entry-count cap (100) is far
// from the limit.
const cache = createFrameSourceCache(100, 4 * 1024);
const a = writeFrame("a.png", 1024);
const b = writeFrame("b.png", 1024);
const c = writeFrame("c.png", 1024);
await cache.get(a);
await cache.get(b);
expect(cache.stats().entries).toBe(2);
await cache.get(c);
const afterC = cache.stats();
// The byte budget is the contract — the cache MUST stay under it after
// an insert that would otherwise overflow. Entry count is incidental.
expect(afterC.bytes).toBeLessThanOrEqual(4 * 1024);
expect(afterC.entries).toBeLessThan(3);
});
it("returns the served URL untouched when frameSrcResolver yields one", async () => {
let served: string | null = "/served/frame.png";
const cache = createFrameSourceCache(4, 64 * 1024, () => served);
const file = writeFrame("a.png", 256);
expect(await cache.get(file)).toBe("/served/frame.png");
// Cache stays empty because the resolver short-circuits the read.
expect(cache.stats()).toMatchObject({ entries: 0, bytes: 0 });
served = null;
const dataUri = await cache.get(file);
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
expect(cache.stats().entries).toBe(1);
});
it("treats a re-read as a cache hit (no second file read)", async () => {
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
const a = writeFrame("a.png", 64);
const first = await cache.get(a);
const second = await cache.get(a);
expect(second).toBe(first);
expect(cache.stats().entries).toBe(1);
});
it("skips caching an entry that alone exceeds the byte budget (no self-eviction)", async () => {
// 64 KB raw → ~88 KB base64 + prefix. Budget of 32 KB rejects this entry.
// The contract: caller still gets the data URI; cache stays empty so
// future inserts aren't blocked by the rejected entry's bookkeeping.
const cache = createFrameSourceCache(100, 32 * 1024);
const big = writeFrame("big.png", 64 * 1024);
const dataUri = await cache.get(big);
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
expect(cache.stats().entries).toBe(0);
expect(cache.stats().bytes).toBe(0);
expect(cache.stats().oversizedRejections).toBe(1);
expect(cache.stats().evictions).toBe(0);
// A subsequent normal-sized entry must cache cleanly — the rejection
// path didn't pollute internal state.
const small = writeFrame("small.png", 1024);
await cache.get(small);
expect(cache.stats().entries).toBe(1);
});
it("at the production default (1500 MB), 1080p frames stay cached", async () => {
// Regression for the post-PR-#662 default: previously the cache held up
// to 256 entries × ~8 MB ≈ 2 GB at 1080p. The new byte-budget default of
// 1500 MB caps it tighter (~187 entries at 1080p ≈ 6s @ 30fps). This
// test pins the math so a future tweak to the default is visible.
const oneEightyP_jpegSize = 8 * 1024 * 1024; // ~8 MB JPEG (data URI)
const defaultBytesLimit = DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb * 1024 * 1024;
const expectedMaxEntries = Math.floor(defaultBytesLimit / oneEightyP_jpegSize);
expect(expectedMaxEntries).toBeGreaterThanOrEqual(180);
expect(expectedMaxEntries).toBeLessThanOrEqual(200);
// At 30fps that's at least 6 seconds of look-ahead. Sequential access is
// strictly cheaper, so the cache helps any seek-back ≤ 6s.
expect(expectedMaxEntries / 30).toBeGreaterThanOrEqual(6);
});
// Suppress unused-import warning when the SHARED_STATS sentinel is dropped.
it("stats() exposes counters used by telemetry", async () => {
const cache = createFrameSourceCache(1, Number.MAX_SAFE_INTEGER);
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
});
});