From 128f3413994fc4f6e8b9c9a6423d18d366da00b0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 15:06:01 +0300 Subject: [PATCH] feat: extract FontEvictionPolicy with TTL and pin/unpin --- .../FontEvictionPolicy.test.ts | 52 +++++++++++++++ .../fontEvictionPolicy/FontEvictionPolicy.ts | 66 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts new file mode 100644 index 0000000..400a39f --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts @@ -0,0 +1,52 @@ +import { FontEvictionPolicy } from './FontEvictionPolicy'; + +describe('FontEvictionPolicy', () => { + let policy: FontEvictionPolicy; + const TTL = 1000; + const t0 = 100000; + + beforeEach(() => { + policy = new FontEvictionPolicy({ ttl: TTL }); + }); + + it('shouldEvict returns false within TTL', () => { + policy.touch('a@400', t0); + expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false); + }); + + it('shouldEvict returns true at TTL boundary', () => { + policy.touch('a@400', t0); + expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true); + }); + + it('shouldEvict returns false for pinned key regardless of TTL', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false); + }); + + it('shouldEvict returns true again after unpin past TTL', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + policy.unpin('a@400'); + expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true); + }); + + it('shouldEvict returns false for untracked key', () => { + expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false); + }); + + it('keys returns all tracked keys', () => { + policy.touch('a@400', t0); + policy.touch('b@vf', t0); + expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf'])); + }); + + it('clear resets all state', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + policy.clear(); + expect(Array.from(policy.keys())).toHaveLength(0); + expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts new file mode 100644 index 0000000..d692684 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts @@ -0,0 +1,66 @@ +interface FontEvictionPolicyOptions { + /** TTL in milliseconds. Defaults to 5 minutes. */ + ttl?: number; +} + +/** + * Tracks font usage timestamps and pinned keys to determine when a font should be evicted. + * + * Pure data — no browser APIs. Accepts explicit `now` timestamps so tests + * never need fake timers. + */ +export class FontEvictionPolicy { + #usageTracker = new Map(); + #pinnedFonts = new Set(); + + readonly #TTL: number; + + constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) { + this.#TTL = ttl; + } + + /** + * Records the last-used time for a font key. + * @param key - Font key in `{id}@{weight}` or `{id}@vf` format. + * @param now - Current timestamp in ms. Defaults to `Date.now()`. + */ + touch(key: string, now: number = Date.now()): void { + this.#usageTracker.set(key, now); + } + + /** Pins a font key so it is never evicted regardless of TTL. */ + pin(key: string): void { + this.#pinnedFonts.add(key); + } + + /** Unpins a font key, allowing it to be evicted once its TTL expires. */ + unpin(key: string): void { + this.#pinnedFonts.delete(key); + } + + /** + * Returns `true` if the font should be evicted. + * A font is evicted when its TTL has elapsed and it is not pinned. + * Returns `false` for untracked keys. + * + * @param key - Font key to check. + * @param now - Current timestamp in ms (pass explicitly for deterministic tests). + */ + shouldEvict(key: string, now: number): boolean { + const lastUsed = this.#usageTracker.get(key); + if (lastUsed === undefined) return false; + if (this.#pinnedFonts.has(key)) return false; + return now - lastUsed >= this.#TTL; + } + + /** Returns an iterator over all tracked font keys. */ + keys(): IterableIterator { + return this.#usageTracker.keys(); + } + + /** Clears all usage timestamps and pinned keys. */ + clear(): void { + this.#usageTracker.clear(); + this.#pinnedFonts.clear(); + } +}