diff --git a/src/plugins/nicojk/App.tsx b/src/plugins/nicojk/App.tsx index 28c4769..b785eb0 100644 --- a/src/plugins/nicojk/App.tsx +++ b/src/plugins/nicojk/App.tsx @@ -1309,18 +1309,6 @@ export default function App() { return false; } - const { startAt, duration } = getBaseTiming(playable); - const primarySource = data.replaySources[0]; - if ( - !Number.isFinite(startAt) || - startAt <= 0 || - !Number.isFinite(duration) || - duration <= 0 || - primarySource.endAt <= primarySource.startAt - ) { - return false; - } - let mgr = kakologManagersRef.current.get(playerID); if (!mgr) { mgr = new KakologManager(); @@ -1328,6 +1316,7 @@ export default function App() { } mgr.setSources(data.replaySources); if (data.jkContext && data.replaySources[0]) { + const { startAt, duration } = getBaseTiming(playable); data.jkContext = withKakologSourceStates( buildJkContext( data.replaySources[0], @@ -1339,6 +1328,7 @@ export default function App() { ); } + const { duration } = getBaseTiming(playable); const currentPlayableId = playable.id; data.isLoadingRecordedComments = true; data.recordedSourcesPendingFetch = false; @@ -1402,8 +1392,7 @@ export default function App() { latest.comments = fetchedComments; latest.recordedFetchProgress = null; latest.isLoadingRecordedComments = false; - latest.recordedCommentsReady = - fetchedComments.length > 0 || mgr.isFullyCompleted(); + latest.recordedCommentsReady = true; latest.interruptedSources = mgr.getInterruptedSources(); if (latest.jkContext) { latest.jkContext = withKakologSourceStates(latest.jkContext, mgr); diff --git a/src/plugins/nicojk/comment-buffer.ts b/src/plugins/nicojk/comment-buffer.ts new file mode 100644 index 0000000..06421e5 --- /dev/null +++ b/src/plugins/nicojk/comment-buffer.ts @@ -0,0 +1,31 @@ +import type { NiconicoComment } from "./comment-client"; + +/** Forget IDs evicted from the live buffer, including comments filtered out by NG settings. */ +export function getPendingLiveComments( + comments: NiconicoComment[], + processedIds: Set, +) { + const retainedIds = new Set(comments.map((comment) => comment.id)); + for (const id of processedIds) { + if (!retainedIds.has(id)) processedIds.delete(id); + } + return comments.filter((comment) => !processedIds.has(comment.id)); +} + +export function getCommentRowKey(comment: NiconicoComment) { + return `${comment.no}-${comment.id}`; +} + +/** TanStack Virtual keeps measured sizes after rows leave the list unless we remove them. */ +export function pruneCommentMeasurementCache( + cache: Map, + comments: NiconicoComment[], +) { + if (cache.size === 0) return; + const staleKeys = new Set(cache.keys()); + for (const comment of comments) { + staleKeys.delete(getCommentRowKey(comment)); + if (staleKeys.size === 0) return; + } + for (const key of staleKeys) cache.delete(key); +} diff --git a/src/plugins/nicojk/comment-renderer.ts b/src/plugins/nicojk/comment-renderer.ts new file mode 100644 index 0000000..4714eb2 --- /dev/null +++ b/src/plugins/nicojk/comment-renderer.ts @@ -0,0 +1,70 @@ +import NiconiComments, { + type FormattedComment, +} from "@xpadev-net/niconicomments"; + +/** Owns a canvas for one renderer generation; destroyed WebGL contexts cannot be reused. */ +export class CommentRenderer { + private canvas: HTMLCanvasElement; + private readonly renderer: NiconiComments; + private destroyed = false; + + constructor( + container: HTMLElement, + comments: FormattedComment[], + format: "empty" | "formatted", + ) { + this.canvas = container.ownerDocument.createElement("canvas"); + this.canvas.width = 1920; + this.canvas.height = 1080; + this.canvas.style.width = "100%"; + this.canvas.style.height = "100%"; + this.canvas.style.display = "block"; + container.append(this.canvas); + try { + this.renderer = new NiconiComments(this.canvas, comments, { + format, + lazy: true, + }); + this.adoptRendererCanvas(container); + } catch (error) { + this.adoptRendererCanvas(container); + this.releaseCanvas(); + throw error; + } + } + + addComments(...comments: FormattedComment[]) { + this.renderer.addComments(...comments); + } + + drawCanvas(vpos: number) { + this.renderer.drawCanvas(vpos); + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + try { + // clear() only erases pixels; destroy() releases listeners, timers and GL resources. + this.renderer.destroy(); + } finally { + this.releaseCanvas(); + } + } + + private adoptRendererCanvas(container: HTMLElement) { + // niconicomments may replace the canvas when falling back from WebGL to 2D. + const canvas = container.querySelector("canvas"); + if (canvas && canvas !== this.canvas) { + this.canvas.width = 0; + this.canvas.height = 0; + this.canvas = canvas; + } + } + + private releaseCanvas() { + this.canvas.remove(); + this.canvas.width = 0; + this.canvas.height = 0; + } +} diff --git a/src/plugins/nicojk/components/OverlayPage.tsx b/src/plugins/nicojk/components/OverlayPage.tsx index 7a04fa4..428222c 100644 --- a/src/plugins/nicojk/components/OverlayPage.tsx +++ b/src/plugins/nicojk/components/OverlayPage.tsx @@ -1,10 +1,10 @@ -import NiconiComments, { - type FormattedComment, -} from "@xpadev-net/niconicomments"; +import type { FormattedComment } from "@xpadev-net/niconicomments"; import { useEffect, useRef, useState } from "react"; import type { PlayerPlaybackState } from "../../../vendor/Plugin"; +import { getPendingLiveComments } from "../comment-buffer"; import type { NiconicoComment } from "../comment-client"; import { isCommentNGBySettings } from "../comment-dedupe"; +import { CommentRenderer } from "../comment-renderer"; import { getCommentSourceKeyForComment } from "../comment-source"; import type { NicoJKContext } from "../context"; import { @@ -233,8 +233,8 @@ export default function OverlayPage({ playbackState, jkContext, }: Props) { - const canvasRef = useRef(null); - const rendererRef = useRef(null); + const canvasContainerRef = useRef(null); + const rendererRef = useRef(null); const rendererMetaRef = useRef<{ mode: RendererMode; playableId: string | null; @@ -330,7 +330,7 @@ export default function OverlayPage({ useEffect(() => { return () => { - rendererRef.current?.clear(); + rendererRef.current?.destroy(); rendererRef.current = null; rendererMetaRef.current = null; renderedLiveCommentIdsRef.current.clear(); @@ -345,7 +345,7 @@ export default function OverlayPage({ }, [playableId]); useEffect(() => { - if (!canvasRef.current) return; + if (!canvasContainerRef.current) return; const recordedRendererPhase: RecordedRendererPhase = !recordedCommentsReady ? "none" @@ -355,7 +355,7 @@ export default function OverlayPage({ const shouldCreateRenderer = hasDisplayCandidates && showComments; if (!shouldCreateRenderer) { if (rendererRef.current) { - rendererRef.current.clear(); + rendererRef.current.destroy(); rendererRef.current = null; rendererMetaRef.current = null; renderedLiveCommentIdsRef.current.clear(); @@ -367,7 +367,9 @@ export default function OverlayPage({ const nextMode: RendererMode = isLive ? "live" : "recorded"; const commentTimingSignature = getCommentTimingSignature(jkContext); - const commentDataSignature = getCommentDataSignature(comments); + const commentDataSignature = isLive + ? "" + : getCommentDataSignature(comments); const shouldRecreate = !rendererRef.current || rendererMetaRef.current?.mode !== nextMode || @@ -387,7 +389,8 @@ export default function OverlayPage({ return; } - rendererRef.current?.clear(); + rendererRef.current?.destroy(); + rendererRef.current = null; const currentSettings = getSettings(); const usesFormattedRenderer = !isLive && (recordedRendererPhase !== "none" || comments.length > 0); @@ -414,10 +417,11 @@ export default function OverlayPage({ currentSettings, ) : []; - const renderer = new NiconiComments(canvasRef.current, initialComments, { - format: usesFormattedRenderer ? "formatted" : "empty", - lazy: true, - }); + const renderer = new CommentRenderer( + canvasContainerRef.current, + initialComments, + usesFormattedRenderer ? "formatted" : "empty", + ); if (isLive && liveComments.length > 0) { renderer.addComments(...liveComments); } @@ -507,8 +511,9 @@ export default function OverlayPage({ return; } - const pendingComments = comments.filter( - (comment) => !renderedLiveCommentIdsRef.current.has(comment.id), + const pendingComments = getPendingLiveComments( + comments, + renderedLiveCommentIdsRef.current, ); if (pendingComments.length === 0) { return; @@ -553,10 +558,8 @@ export default function OverlayPage({ return (
- { const item = displayComments[index]; - return item ? `${item.no}-${item.id}` : index; + return item ? getCommentRowKey(item) : index; }, paddingEnd: safeAreaInsetBottom, scrollPaddingEnd: safeAreaInsetBottom + 4, }); + useEffect(() => { + pruneCommentMeasurementCache( + rowVirtualizer.itemSizeCache, + hasActivePlayer ? displayComments : [], + ); + }, [displayComments, hasActivePlayer, rowVirtualizer]); + const findCommentIndexByVpos = useCallback( (targetVpos: number) => { let low = 0; diff --git a/src/plugins/nicojk/kakolog-manager.ts b/src/plugins/nicojk/kakolog-manager.ts index 99e47aa..6d81f5b 100644 --- a/src/plugins/nicojk/kakolog-manager.ts +++ b/src/plugins/nicojk/kakolog-manager.ts @@ -97,6 +97,8 @@ interface SourceFetchState { fetchedOffsets: Set; niconicoFetchedOffsets: Set; miyouFetchedOffsets: Set; + /** 現在分が確定してから再取得するチャンクと、その最短再試行時刻 */ + deferredOffsets: Map; needsInitialFetch: boolean; completed: boolean; interrupted: boolean; @@ -108,6 +110,7 @@ interface SourceChunkFetchResult { comments: NiconicoComment[]; niconicoFetched: boolean; miyouFetched: boolean; + deferredUntil: number | null; } function getMiyouDuplicateKey(comment: NiconicoComment): string | null { @@ -237,7 +240,8 @@ export class KakologManager { fetchedOffsets: new Set(), niconicoFetchedOffsets: new Set(), miyouFetchedOffsets: new Set(), - needsInitialFetch: true, + deferredOffsets: new Map(), + needsInitialFetch: false, completed: false, interrupted: false, ignoreLimit: false, @@ -297,6 +301,7 @@ export class KakologManager { fetchedOffsets: new Set(), niconicoFetchedOffsets: new Set(), miyouFetchedOffsets: new Set(), + deferredOffsets: new Map(), needsInitialFetch: true, completed: false, interrupted: false, @@ -323,6 +328,7 @@ export class KakologManager { state.fetchedOffsets.clear(); state.niconicoFetchedOffsets.clear(); state.miyouFetchedOffsets.clear(); + state.deferredOffsets.clear(); state.needsInitialFetch = false; state.completed = false; state.interrupted = false; @@ -399,18 +405,24 @@ export class KakologManager { ); if (windowDuration <= 0) continue; const sourceStart = Math.floor(state.source.startAt + offset); - const sourceEnd = Math.floor( + const requestedSourceEnd = Math.floor( Math.min( state.source.startAt + offset + windowDuration, state.source.endAt, - Math.floor(Date.now() / 60_000) * 60, ), ); + const currentMinuteStart = Math.floor(Date.now() / 60_000) * 60; + const sourceEnd = Math.min(requestedSourceEnd, currentMinuteStart); + const deferredUntil = + sourceEnd < requestedSourceEnd ? currentMinuteStart + 60 : null; if (sourceStart >= sourceEnd) { // まだ確定していない現在の分は、通常取得と同様に再試行へ残す。 state.miyouFetchedOffsets.delete(offset); state.fetchedOffsets.delete(offset); - state.needsInitialFetch = state.fetchedOffsets.size === 0; + if (deferredUntil != null) { + state.deferredOffsets.set(offset, deferredUntil); + } + state.needsInitialFetch = false; hasMiyouFailure = true; continue; } @@ -435,7 +447,7 @@ export class KakologManager { state.commentCount += addedCount; this.totalFetched += addedCount; - if (fetched.success) { + if (fetched.success && deferredUntil == null) { state.miyouFetchedOffsets.add(offset); } else { // Miyou の失敗区間だけを次回の通常取得で再試行できるようにする。 @@ -443,11 +455,15 @@ export class KakologManager { state.fetchedOffsets.delete(offset); hasMiyouFailure = true; } + if (deferredUntil != null) { + state.deferredOffsets.set(offset, deferredUntil); + } if ( state.niconicoFetchedOffsets.has(offset) && state.miyouFetchedOffsets.has(offset) ) { state.fetchedOffsets.add(offset); + state.deferredOffsets.delete(offset); } else { state.fetchedOffsets.delete(offset); } @@ -624,6 +640,7 @@ export class KakologManager { if (!primary || primary.completed) return false; for (const offset of primary.applicableOffsets) { if (primary.fetchedOffsets.has(offset)) continue; + if (this.isDeferredOffsetWaiting(primary, offset)) continue; if (playerTime >= offset && playerTime < offset + KAKOLOG_CHUNK_SIZE) { return true; } @@ -644,6 +661,7 @@ export class KakologManager { if (state.completed) continue; for (const offset of state.applicableOffsets) { if (state.fetchedOffsets.has(offset)) continue; + if (this.isDeferredOffsetWaiting(state, offset)) continue; if (offset < playerTime) continue; if (nextUnfetched == null || offset < nextUnfetched) { nextUnfetched = offset; @@ -661,9 +679,15 @@ export class KakologManager { ); } - /** 新規・取得条件更新・初回失敗で、初回取得待ちのソースがあるか */ + /** 初回取得待ち、または現在分の確定後に自動再取得すべきソースがあるか */ public hasPendingInitialSourceFetch(): boolean { - return this.sourceStates.some((state) => state.needsInitialFetch); + return this.sourceStates.some( + (state) => + state.needsInitialFetch || + [...state.deferredOffsets.values()].some( + (retryAt) => retryAt <= Date.now() / 1000, + ), + ); } public async fetchWithLimit( @@ -808,6 +832,13 @@ export class KakologManager { for (const offset of orderedOffsets) { if (revision !== this.fetchRevision) break; if (state.fetchedOffsets.has(offset)) continue; + if (this.isDeferredOffsetWaiting(state, offset)) { + if (this.progressState) { + this.progressState.skippedRequests += 1; + } + continue; + } + state.deferredOffsets.delete(offset); const fetchNiconico = !state.niconicoFetchedOffsets.has(offset); const fetchMiyou = this.shouldFetchMiyou(state.source) && @@ -857,6 +888,9 @@ export class KakologManager { if (fetched.miyouFetched) { state.miyouFetchedOffsets.add(offset); } + if (fetched.deferredUntil != null) { + state.deferredOffsets.set(offset, fetched.deferredUntil); + } if ( state.niconicoFetchedOffsets.has(offset) && (!fetchMiyou || state.miyouFetchedOffsets.has(offset)) @@ -884,7 +918,10 @@ export class KakologManager { if (miyouKey) existingMiyouKeys.add(miyouKey); return true; }); - if ( + if (fetched.deferredUntil != null) { + // API が公開していない現在分だけが残っている。時刻到達後に再試行する。 + state.needsInitialFetch = false; + } else if ( (!fetchNiconico || fetched.niconicoFetched) && (!fetchMiyou || fetched.miyouFetched) ) { @@ -994,6 +1031,11 @@ export class KakologManager { return null; } + private isDeferredOffsetWaiting(state: SourceFetchState, offset: number) { + const retryAt = state.deferredOffsets.get(offset); + return retryAt != null && retryAt > Date.now() / 1000; + } + private countRemainingRequests(states: SourceFetchState[]): number { let total = 0; for (const state of states) { @@ -1063,9 +1105,12 @@ export class KakologManager { const sourceStart = Math.floor(source.startAt + offset); // 過去ログ API の終端は現在時刻の分開始(秒=0)を超えないようにする。 const currentMinuteStart = Math.floor(Date.now() / 60_000) * 60; - const sourceEnd = Math.floor( - Math.min(sourceStart + windowDuration, source.endAt, currentMinuteStart), + const requestedSourceEnd = Math.floor( + Math.min(sourceStart + windowDuration, source.endAt), ); + const sourceEnd = Math.min(requestedSourceEnd, currentMinuteStart); + const deferredUntil = + sourceEnd < requestedSourceEnd ? currentMinuteStart + 60 : null; if (sourceStart >= sourceEnd) { // 録画開始直後は開始時刻が currentMinuteStart より後になりうる。 // まだ過去ログ API に問い合わせられない区間を取得済みにすると、 @@ -1074,6 +1119,7 @@ export class KakologManager { comments: [], niconicoFetched: !fetchNiconico, miyouFetched: !fetchMiyou, + deferredUntil, }; } @@ -1141,7 +1187,7 @@ export class KakologManager { }, ]; }); - niconicoFetched = true; + niconicoFetched = deferredUntil == null; } } catch (error) { console.error( @@ -1161,13 +1207,14 @@ export class KakologManager { sourceOrdinal, }); miyouComments = fetched.comments; - miyouFetched = fetched.success; + miyouFetched = fetched.success && deferredUntil == null; } return { comments: [...niconicoComments, ...miyouComments], niconicoFetched, miyouFetched, + deferredUntil, }; } diff --git a/tests/nicojk-comment-buffer.test.ts b/tests/nicojk-comment-buffer.test.ts new file mode 100644 index 0000000..ee352ed --- /dev/null +++ b/tests/nicojk-comment-buffer.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Virtualizer } from "@tanstack/react-virtual"; +import { + getCommentRowKey, + getPendingLiveComments, + pruneCommentMeasurementCache, +} from "../src/plugins/nicojk/comment-buffer"; +import type { NiconicoComment } from "../src/plugins/nicojk/comment-client"; + +function comment(id: number): NiconicoComment { + return { + id, + no: id, + vpos: id, + content: "NG comment", + date: 0, + date_usec: 0, + mail: [], + user_id: "blocked-user", + premium: 0, + anonymity: 1, + }; +} + +test("live IDs stay bounded when all incoming comments are filtered out", () => { + const processedIds = new Set(); + let comments: NiconicoComment[] = []; + for (let id = 0; id < 10_000; id++) { + comments = [...comments.slice(-999), comment(id)]; + const pending = getPendingLiveComments(comments, processedIds); + assert.deepEqual( + pending.map((item) => item.id), + [id], + ); + // The overlay marks NG comments as processed without adding them to the renderer. + for (const item of pending) processedIds.add(item.id); + assert.ok(processedIds.size <= 1000); + } + assert.deepEqual(getPendingLiveComments(comments, processedIds), []); + getPendingLiveComments([], processedIds); + assert.equal(processedIds.size, 0); +}); + +test("panel retains measured sizes only for comments still in the list", () => { + const virtualizer = new Virtualizer({ + count: 1000, + getScrollElement: () => null, + estimateSize: () => 41, + scrollToFn: () => {}, + observeElementRect: () => {}, + observeElementOffset: () => {}, + }); + let comments = Array.from({ length: 1000 }, (_, id) => comment(id)); + for (let id = 1000; id < 5000; id++) { + comments = [...comments.slice(1), comment(id)]; + virtualizer.setOptions({ + ...virtualizer.options, + getItemKey: (index) => getCommentRowKey(comments[index]), + }); + virtualizer.getTotalSize(); + virtualizer.resizeItem(999, 42); + pruneCommentMeasurementCache(virtualizer.itemSizeCache, comments); + assert.ok(virtualizer.itemSizeCache.size <= 1000); + assert.equal( + virtualizer.itemSizeCache.get(getCommentRowKey(comment(id))), + 42, + ); + } + const lastComment = comments.at(-1); + assert.ok(lastComment); + pruneCommentMeasurementCache(virtualizer.itemSizeCache, [lastComment]); + assert.equal(virtualizer.itemSizeCache.size, 1); + pruneCommentMeasurementCache(virtualizer.itemSizeCache, []); + assert.equal(virtualizer.itemSizeCache.size, 0); +}); diff --git a/tests/nicojk-comment-renderer.test.ts b/tests/nicojk-comment-renderer.test.ts new file mode 100644 index 0000000..231804b --- /dev/null +++ b/tests/nicojk-comment-renderer.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { CommentRenderer } from "../src/plugins/nicojk/comment-renderer"; + +/** Count resources owned by the installed renderer without requiring a GPU. */ +function createBrowserStandIns(t: TestContext, fallback = false) { + const resources = { + programs: new Set(), + buffers: new Set(), + vertexArrays: new Set(), + textures: new Set(), + }; + const resourceMethods = new Map([ + ["createProgram", resources.programs], + ["deleteProgram", resources.programs], + ["createBuffer", resources.buffers], + ["deleteBuffer", resources.buffers], + ["createVertexArray", resources.vertexArrays], + ["deleteVertexArray", resources.vertexArrays], + ["createTexture", resources.textures], + ["deleteTexture", resources.textures], + ]); + const timers = new Map void>(); + let nextTimer = 0; + const clearTimeout = globalThis.clearTimeout; + t.mock.method( + globalThis, + "clearTimeout", + (timer: Parameters[0]) => { + if (typeof timer === "number") timers.delete(timer); + else clearTimeout(timer); + }, + ); + const canvases: CanvasStandIn[] = []; + class CanvasStandIn { + width = 300; + height = 150; + style: Record = {}; + listeners = new Map void>>(); + parentNode: typeof container | null = null; + contextLost = false; + gl = new Proxy( + {}, + { + get: (_target, name) => { + const resourceSet = resourceMethods.get(String(name)); + if (resourceSet) { + return (resource: object) => { + if (String(name).startsWith("delete")) { + resourceSet.delete(resource); + return; + } + const created = {}; + resourceSet.add(created); + return created; + }; + } + if (name === "getExtension") + return () => ({ + loseContext: () => { + this.contextLost = true; + assert.equal(this.listenerCount(), 0); + }, + }); + if (name === "isContextLost") return () => this.contextLost; + if (name === "createShader" || name === "getUniformLocation") + return () => ({}); + if (name === "getShaderParameter" || name === "getProgramParameter") + return () => true; + if (name === "getParameter") return () => 4096; + if (typeof name === "string" && /^[A-Z0-9_]+$/.test(name)) return 1; + return () => {}; + }, + }, + ); + + constructor() { + canvases.push(this); + } + + getContext(kind: string) { + if (fallback && canvases[0] === this) return null; + if (kind === "webgl2") return this.contextLost ? null : this.gl; + return new Proxy( + { font: "10px sans-serif" }, + { + get: (target, name) => { + if (name in target) return Reflect.get(target, name); + if (name === "getTransform") + return () => ({ a: 1, d: 1, e: 0, f: 0 }); + if (name === "getImageData") + return () => ({ data: [255, 255, 255, 255] }); + if (name === "measureText") + return (text: string) => ({ + width: text.length * 20, + actualBoundingBoxAscent: 20, + actualBoundingBoxDescent: 5, + }); + return () => {}; + }, + }, + ); + } + + addEventListener(name: string, listener: () => void) { + if (!this.listeners.has(name)) this.listeners.set(name, new Set()); + this.listeners.get(name)?.add(listener); + } + + removeEventListener(name: string, listener: () => void) { + this.listeners.get(name)?.delete(listener); + } + + listenerCount() { + return [...this.listeners.values()].reduce( + (count, listeners) => count + listeners.size, + 0, + ); + } + + remove() { + if (!this.parentNode) return; + this.parentNode.children = this.parentNode.children.filter( + (child) => child !== this, + ); + this.parentNode = null; + } + + replaceWith(canvas: CanvasStandIn) { + const parent = this.parentNode; + this.remove(); + parent?.append(canvas); + } + } + const document = { createElement: () => new CanvasStandIn() }; + const container = { + ownerDocument: document, + children: [] as CanvasStandIn[], + append(canvas: CanvasStandIn) { + this.children.push(canvas); + canvas.parentNode = this; + }, + querySelector: () => container.children[0] ?? null, + }; + for (const [name, value] of Object.entries({ + document, + window: { + setTimeout(callback: () => void) { + timers.set(++nextTimer, callback); + return nextTimer; + }, + }, + HTMLCanvasElement: CanvasStandIn, + HTMLVideoElement: class {}, + })) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { configurable: true, value }); + t.after(() => { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + }); + } + return { container, canvases, resources, timers }; +} + +test("repeated renderer rebuilds release GL resources and use a fresh canvas", (t) => { + const { container, canvases, resources } = createBrowserStandIns(t); + for (let generation = 0; generation < 100; generation++) { + const renderer = new CommentRenderer( + container as unknown as HTMLElement, + [], + "empty", + ); + const canvas = container.children[0]; + assert.equal(container.children.length, 1); + assert.equal(canvas.width, 1920); + assert.equal(canvas.height, 1080); + assert.equal(canvas.contextLost, false); + assert.equal(canvas.listenerCount(), 2); + assert.equal(resources.programs.size, 2); + renderer.destroy(); + renderer.destroy(); + assert.equal(container.children.length, 0); + assert.equal(canvas.listenerCount(), 0); + assert.equal(canvas.contextLost, true); + assert.equal(canvas.width, 0); + assert.equal(canvas.height, 0); + for (const resourceSet of Object.values(resources)) { + assert.equal(resourceSet.size, 0); + } + } + const displayedCanvases = canvases.filter((canvas) => canvas.contextLost); + assert.equal(new Set(displayedCanvases).size, 100); +}); + +test("disposes the replacement canvas after the library falls back to 2D", (t) => { + const { container, canvases } = createBrowserStandIns(t, true); + t.mock.method(console, "warn", () => {}); + const renderer = new CommentRenderer( + container as unknown as HTMLElement, + [], + "empty", + ); + const replacement = container.children[0]; + assert.notEqual(replacement, canvases[0]); + assert.equal(canvases[0].width, 0); + assert.equal(replacement.width, 1920); + renderer.destroy(); + assert.equal(container.children.length, 0); + assert.equal(replacement.width, 0); + assert.equal(replacement.height, 0); +}); + +test("rebuilding after drawing live comments releases textures and image timers", (t) => { + const { container, resources, timers } = createBrowserStandIns(t); + for (let id = 0; id < 20; id++) { + const renderer = new CommentRenderer( + container as unknown as HTMLElement, + [], + "empty", + ); + renderer.addComments({ + id, + vpos: 1000, + content: `live comment ${id}`, + date: 0, + date_usec: 0, + owner: false, + premium: false, + mail: [], + user_id: -1, + layer: 0, + is_my_post: false, + }); + renderer.drawCanvas(1100); + assert.ok(resources.textures.size > 0); + assert.ok(timers.size > 0); + renderer.destroy(); + assert.equal(resources.textures.size, 0); + assert.equal(timers.size, 0); + } +}); diff --git a/tests/nicojk-kakolog-manager.test.ts b/tests/nicojk-kakolog-manager.test.ts index fd6eec8..e10a2e4 100644 --- a/tests/nicojk-kakolog-manager.test.ts +++ b/tests/nicojk-kakolog-manager.test.ts @@ -57,7 +57,7 @@ test("does not complete an empty source interval before timing arrives", async ( assert.equal(manager.isFullyCompleted(), false); manager.setSources([source]); - assert.equal(manager.hasPendingInitialSourceFetch(), true); + assert.equal(manager.hasPendingInitialSourceFetch(), false); const comments = await manager.fetchWithLimit(1800); assert.equal(requestedUrls.length, 1); assert.equal( @@ -87,7 +87,7 @@ test("refetches a completed empty result when the same source interval grows", a manager.setSources([source]); assert.equal(manager.isFullyCompleted(), false); - assert.equal(manager.hasPendingInitialSourceFetch(), true); + assert.equal(manager.hasPendingInitialSourceFetch(), false); const comments = await manager.fetchWithLimit(1800); assert.equal(fetchCount, 2); assert.equal(comments[0]?.content, "更新後のコメント"); @@ -206,9 +206,10 @@ test("retries a recording that is too recent for the kakolog API", async (t) => assert.deepEqual(await manager.fetchWithLimit(5), []); assert.equal(fetchCount, 0); assert.equal(manager.isFullyCompleted(), false); - assert.equal(manager.hasPendingInitialSourceFetch(), true); + assert.equal(manager.hasPendingInitialSourceFetch(), false); now = minuteStart + 65; + assert.equal(manager.hasPendingInitialSourceFetch(), true); const comments = await manager.fetchWithLimit(5); assert.equal(fetchCount, 1); @@ -217,3 +218,89 @@ test("retries a recording that is too recent for the kakolog API", async (t) => assert.equal(manager.isFullyCompleted(), true); assert.equal(manager.hasPendingInitialSourceFetch(), false); }); + +test("retries a chunk whose end was clipped to the current minute", async (t) => { + const originalNow = Date.now; + const originalFetch = globalThis.fetch; + const minuteStart = 1_700_000_040; + let now = minuteStart + 45; + const requestedUrls: URL[] = []; + + Date.now = () => now * 1000; + globalThis.fetch = async (input) => { + requestedUrls.push(new URL(String(input))); + const chats = [ + { + chat: { + id: "1", + no: "1", + vpos: "0", + content: "確定済みのコメント", + date: String(minuteStart - 10), + date_usec: "0", + mail: "", + user_id: "user-1", + }, + }, + ]; + if (requestedUrls.length > 1) { + chats.push({ + chat: { + id: "2", + no: "2", + vpos: "0", + content: "後から確定したコメント", + date: String(minuteStart + 20), + date_usec: "0", + mail: "", + user_id: "user-2", + }, + }); + } + return new Response(JSON.stringify({ packet: chats }), { status: 200 }); + }; + t.after(() => { + Date.now = originalNow; + globalThis.fetch = originalFetch; + }); + + const source: ResolvedCommentSource = { + key: `primary:jk1:na:${minuteStart - 20}`, + kind: "primary", + jkId: "jk1", + channelName: "Primary", + startAt: minuteStart - 20, + endAt: minuteStart + 40, + programStartAt: minuteStart - 20, + }; + const manager = new KakologManager(); + manager.setSources([source]); + + const partialComments = await manager.fetchWithLimit(60); + assert.equal(partialComments.length, 1); + assert.equal(requestedUrls.length, 1); + assert.equal( + requestedUrls[0]?.searchParams.get("endtime"), + String(minuteStart), + ); + assert.equal(manager.isFullyCompleted(), false); + assert.equal(manager.hasPendingInitialSourceFetch(), false); + + await manager.fetchWithLimit(60); + assert.equal(requestedUrls.length, 1, "分境界までは同じ区間を再取得しない"); + + now = minuteStart + 65; + assert.equal(manager.hasPendingInitialSourceFetch(), true); + const completedComments = await manager.fetchWithLimit(60); + + assert.equal(requestedUrls.length, 2); + assert.equal( + requestedUrls[1]?.searchParams.get("endtime"), + String(minuteStart + 40), + ); + assert.deepEqual( + completedComments.map((comment) => comment.content), + ["確定済みのコメント", "後から確定したコメント"], + ); + assert.equal(manager.isFullyCompleted(), true); +});