diff --git a/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts b/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts new file mode 100644 index 00000000..6890ba7a --- /dev/null +++ b/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +test('getRollupGroupsForSessions uses only localtime rollup keys', () => { + const source = fs.readFileSync( + path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'), + 'utf8', + ); + const start = source.indexOf('export function getRollupGroupsForSessions'); + const end = source.indexOf('export function refreshRollupsForGroupsInTransaction'); + const functionSource = source.slice(start, end); + + assert.match(functionSource, /'unixepoch', 'localtime'/); + assert.doesNotMatch(functionSource, /UNION/); + assert.doesNotMatch(functionSource, /86400000/); +}); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index 4523e49f..c8f91932 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -39,6 +39,7 @@ import { upsertCoverArt, } from '../query.js'; import { + getLocalEpochDay, getShiftedLocalDaySec, getStartOfLocalDayTimestamp, toDbTimestamp, @@ -3776,6 +3777,8 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co ]); assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []); + assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY)); + assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1); } finally { db.close(); cleanupDbPath(dbPath); @@ -4196,8 +4199,14 @@ test('deleteSession removes zero-session media from library and trends', () => { const startedAtMs = 9_000_000; const endedAtMs = startedAtMs + 120_000; - const rollupDay = Math.floor(startedAtMs / 86_400_000); - const rollupMonth = 197001; + const rollupDay = getLocalEpochDay(db, startedAtMs); + const rollupMonth = ( + db + .prepare( + "SELECT CAST(strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollupMonth", + ) + .get(startedAtMs) as { rollupMonth: number } + ).rollupMonth; const { sessionId } = startSessionRecord(db, videoId, startedAtMs); db.prepare( diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 69f1bdbb..1685da55 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -319,16 +319,9 @@ export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[ video_id FROM imm_sessions WHERE session_id IN (${placeholders}) - UNION - SELECT DISTINCT - CAST(CAST(started_at_ms AS REAL) / 86400000 AS INTEGER) AS rollup_day, - CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch') AS INTEGER) AS rollup_month, - video_id - FROM imm_sessions - WHERE session_id IN (${placeholders}) `, ) - .all(...sessionIds, ...sessionIds) as RollupGroupRow[]; + .all(...sessionIds) as RollupGroupRow[]; return rows.map((row) => ({ rollupDay: row.rollup_day, diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index 7f37cbab..d5bddd1a 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -21,6 +21,15 @@ import { nowMs } from './time'; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; +const SENTENCE_SEARCH_DEFAULT_LIMIT = 50; +const SENTENCE_SEARCH_MAX_LIMIT = 100; + +function resolveSentenceSearchLimit(limit: number): number { + if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT; + const normalized = Math.floor(limit); + if (normalized <= 0) return SENTENCE_SEARCH_DEFAULT_LIMIT; + return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT); +} function splitSearchTerms(query: string): string[] { return query @@ -228,10 +237,11 @@ export function getKanjiOccurrences( export function searchSubtitleSentences( db: DatabaseSync, query: string, - limit = 50, + limit = SENTENCE_SEARCH_DEFAULT_LIMIT, ): SentenceSearchResultRow[] { const terms = splitSearchTerms(query); if (terms.length === 0) return []; + const resolvedLimit = resolveSentenceSearchLimit(limit); const clauses: string[] = []; const params: string[] = []; @@ -270,7 +280,7 @@ export function searchSubtitleSentences( LIMIT ? `, ) - .all(...params, limit) as unknown as SentenceSearchResultRow[]; + .all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[]; } export function getSessionEvents( diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts index 0eb6e6d2..bdcaec15 100644 --- a/src/preload-settings.test.ts +++ b/src/preload-settings.test.ts @@ -34,6 +34,15 @@ test('overlay preload buffers only latest subtitle state until renderer listener assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/); }); +test('overlay preload buffers only latest mining image payload before listener registration', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); + + assert.match( + source, + /const onMiningImageEvent =\s*createLatestValueIpcListenerWithPayload\(\s*IPC_CHANNELS\.event\.miningImage,/, + ); +}); + test('overlay preload exposes queued pointer recovery requests', () => { const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); diff --git a/src/preload.ts b/src/preload.ts index fb8a2a18..2b17af56 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -223,7 +223,7 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload payload as SecondarySubMode, ); -const onMiningImageEvent = createQueuedIpcListenerWithPayload( +const onMiningImageEvent = createLatestValueIpcListenerWithPayload( IPC_CHANNELS.event.miningImage, (payload) => payload as MiningImagePayload, ); diff --git a/stats/src/components/vocabulary/pos-helpers.tsx b/stats/src/components/vocabulary/pos-helpers.tsx index 10f2dcc2..4462a041 100644 --- a/stats/src/components/vocabulary/pos-helpers.tsx +++ b/stats/src/components/vocabulary/pos-helpers.tsx @@ -22,7 +22,9 @@ export function posColor(pos: string): string { export function PosBadge({ pos }: { pos: string }) { return ( - + {pos.replace(/_/g, ' ')} );