fix(immersion): use localtime rollup keys and guard sentence search limi

- Remove UTC-based UNION branch from getRollupGroupsForSessions; keep only localtime rollup
- Add resolveSentenceSearchLimit to clamp/sanitize infinity and negative limit values
- Switch onMiningImageEvent from queued to latest-value IPC listener (drop stale frames)
- Add regression tests for rollup key locality and sentence search limit edge cases
This commit is contained in:
2026-06-05 13:08:42 -07:00
parent eb5e07cfc0
commit 10c99b526b
7 changed files with 55 additions and 14 deletions
@@ -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/);
});
@@ -39,6 +39,7 @@ import {
upsertCoverArt, upsertCoverArt,
} from '../query.js'; } from '../query.js';
import { import {
getLocalEpochDay,
getShiftedLocalDaySec, getShiftedLocalDaySec,
getStartOfLocalDayTimestamp, getStartOfLocalDayTimestamp,
toDbTimestamp, toDbTimestamp,
@@ -3776,6 +3777,8 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co
]); ]);
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []); assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY));
assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -4196,8 +4199,14 @@ test('deleteSession removes zero-session media from library and trends', () => {
const startedAtMs = 9_000_000; const startedAtMs = 9_000_000;
const endedAtMs = startedAtMs + 120_000; const endedAtMs = startedAtMs + 120_000;
const rollupDay = Math.floor(startedAtMs / 86_400_000); const rollupDay = getLocalEpochDay(db, startedAtMs);
const rollupMonth = 197001; 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); const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
db.prepare( db.prepare(
@@ -319,16 +319,9 @@ export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[
video_id video_id
FROM imm_sessions FROM imm_sessions
WHERE session_id IN (${placeholders}) 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) => ({ return rows.map((row) => ({
rollupDay: row.rollup_day, rollupDay: row.rollup_day,
@@ -21,6 +21,15 @@ import { nowMs } from './time';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; 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[] { function splitSearchTerms(query: string): string[] {
return query return query
@@ -228,10 +237,11 @@ export function getKanjiOccurrences(
export function searchSubtitleSentences( export function searchSubtitleSentences(
db: DatabaseSync, db: DatabaseSync,
query: string, query: string,
limit = 50, limit = SENTENCE_SEARCH_DEFAULT_LIMIT,
): SentenceSearchResultRow[] { ): SentenceSearchResultRow[] {
const terms = splitSearchTerms(query); const terms = splitSearchTerms(query);
if (terms.length === 0) return []; if (terms.length === 0) return [];
const resolvedLimit = resolveSentenceSearchLimit(limit);
const clauses: string[] = []; const clauses: string[] = [];
const params: string[] = []; const params: string[] = [];
@@ -270,7 +280,7 @@ export function searchSubtitleSentences(
LIMIT ? LIMIT ?
`, `,
) )
.all(...params, limit) as unknown as SentenceSearchResultRow[]; .all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
} }
export function getSessionEvents( export function getSessionEvents(
+9
View File
@@ -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\);/); 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<MiningImagePayload>\(\s*IPC_CHANNELS\.event\.miningImage,/,
);
});
test('overlay preload exposes queued pointer recovery requests', () => { test('overlay preload exposes queued pointer recovery requests', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
+1 -1
View File
@@ -223,7 +223,7 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<Sec
IPC_CHANNELS.event.secondarySubtitleMode, IPC_CHANNELS.event.secondarySubtitleMode,
(payload) => payload as SecondarySubMode, (payload) => payload as SecondarySubMode,
); );
const onMiningImageEvent = createQueuedIpcListenerWithPayload<MiningImagePayload>( const onMiningImageEvent = createLatestValueIpcListenerWithPayload<MiningImagePayload>(
IPC_CHANNELS.event.miningImage, IPC_CHANNELS.event.miningImage,
(payload) => payload as MiningImagePayload, (payload) => payload as MiningImagePayload,
); );
@@ -22,7 +22,9 @@ export function posColor(pos: string): string {
export function PosBadge({ pos }: { pos: string }) { export function PosBadge({ pos }: { pos: string }) {
return ( return (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}> <span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}
>
{pos.replace(/_/g, ' ')} {pos.replace(/_/g, ' ')}
</span> </span>
); );