mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
fix(stats): count mined cards once and keep lexical stats exact after deletes
- Card creation now owns mined-card counting; remove the outer single and multi-line mining increments that double counted each card. - Preserve fractional first_seen/last_seen precision when session deletes compare and refresh lexical aggregates. - Derive vocabulary_visible from the stored word row on every write so the new-word charts agree with the filtered vocabulary summary. - Bump the lexical rollup version so existing databases re-derive visibility and repair stale last_seen values once in the background worker.
This commit is contained in:
18 files changed
+213
-114
No files matched your search
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed sentence mining (single and multi-line) counting each created card twice in session and lifetime stats.
|
||||
- Fixed deleting a session leaving word and kanji "last seen" dates pointing at the deleted session; affected rows are repaired once in the background on next launch.
|
||||
- Fixed re-seen words that the vocabulary filter hides still being counted in the new-words charts, so charts and the vocabulary summary now agree. Existing mismatches are repaired by the same background pass.
|
||||
@@ -30,7 +30,13 @@ function cleanupDbPath(dbPath: string): void {
|
||||
*/
|
||||
function seed(
|
||||
db: DatabaseSync,
|
||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
||||
lines: Array<{
|
||||
session: 1 | 2;
|
||||
wordId: number;
|
||||
dayOffset: number;
|
||||
msOffset?: number;
|
||||
count?: number;
|
||||
}>,
|
||||
options: { legacyRows?: boolean } = {},
|
||||
): void {
|
||||
db.exec(`
|
||||
@@ -65,7 +71,7 @@ function seed(
|
||||
let lineId = 0;
|
||||
for (const line of lines) {
|
||||
lineId += 1;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
|
||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||
if (options.legacyRows) {
|
||||
@@ -83,13 +89,13 @@ function seed(
|
||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||
),
|
||||
first_seen = (
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
),
|
||||
last_seen = (
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
@@ -200,6 +206,32 @@ test('deleting the latest session moves last_seen back to the surviving line', (
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
|
||||
// Stored seen values are fractional seconds; whole-second rounding used to miss
|
||||
// the removed extreme (or truncate the refreshed one).
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
|
||||
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
deleteSession(db, 2);
|
||||
|
||||
const word = readWord(db, 22);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
|
||||
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||
const { db, dbPath } = createDb([
|
||||
|
||||
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
|
||||
assert.ok(sharedWordRow);
|
||||
assert.equal(sharedWordRow.frequency, 1);
|
||||
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyWordRow ?? null, null);
|
||||
assert.ok(sharedKanjiRow);
|
||||
assert.equal(sharedKanjiRow.frequency, 1);
|
||||
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from './query-lexical';
|
||||
import { Database } from './sqlite';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { ensureSchema } from './storage';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
||||
@@ -125,6 +126,109 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
|
||||
}
|
||||
});
|
||||
|
||||
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const firstSeen = BASE_MS / 1000;
|
||||
// Legacy row whose stored merged-token POS fails the persistence filter.
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
|
||||
).run(firstSeen, firstSeen);
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
// The incoming token passes the filter, but the upsert keeps the stored POS.
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: 'にし',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '二死',
|
||||
word: 'にし',
|
||||
reading: 'にし',
|
||||
partOfSpeech: 'verb',
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
pos3: '*',
|
||||
frequencyRank: null,
|
||||
occurrenceCount: 1,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [],
|
||||
firstSeen: firstSeen + 60,
|
||||
lastSeen: firstSeen + 60,
|
||||
},
|
||||
createTrackerPreparedStatements(db),
|
||||
);
|
||||
|
||||
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
|
||||
(sum, day) => sum + day.wordCount,
|
||||
0,
|
||||
);
|
||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||
assert.equal(plotted, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const lineId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, 1, '猫', ?, ?)`,
|
||||
)
|
||||
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
|
||||
);
|
||||
const seen = (BASE_MS + 251) / 1000;
|
||||
const stale = seen + 86_400.631;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
|
||||
).run(seen, stale, seen - 0.1, seen - 0.1);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
|
||||
).run(seen, stale);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251);
|
||||
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
const lastSeen = (table: string): number[] =>
|
||||
(
|
||||
db.prepare(`SELECT last_seen AS value FROM ${table} ORDER BY id`).all() as Array<{
|
||||
value: number;
|
||||
}>
|
||||
).map((row) => row.value);
|
||||
assert.deepEqual(lastSeen('imm_words'), [seen, seen - 0.1], 'healthy rows are untouched');
|
||||
assert.deepEqual(lastSeen('imm_kanji'), [seen]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -268,7 +372,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||
VALUES ('lexical_daily_rollups_version', 2);
|
||||
VALUES ('lexical_daily_rollups_version', 3);
|
||||
`);
|
||||
|
||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||
|
||||
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
|
||||
) - 2440587.5 AS INTEGER)
|
||||
`;
|
||||
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
|
||||
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||
// last_seen values left behind by session deletes.
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||
|
||||
@@ -26,6 +28,27 @@ export function localEpochDaySql(value: string): string {
|
||||
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
||||
}
|
||||
|
||||
// Session deletes once rounded removed timestamps to whole seconds, missing
|
||||
// fractional last_seen extremes that pointed at deleted history. Healthy rows
|
||||
// never exceed their newest dated occurrence (seen_ms is stamped at flush, after
|
||||
// last_seen), so only stale rows match. Rows with undated occurrences are skipped.
|
||||
function repairStaleLastSeen(db: DatabaseSync): void {
|
||||
for (const [table, occurrenceTable, col] of [
|
||||
['imm_words', 'imm_word_line_occurrences', 'word_id'],
|
||||
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
|
||||
] as const) {
|
||||
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
|
||||
db.exec(`
|
||||
UPDATE ${table}
|
||||
SET last_seen = ${newestSeen}
|
||||
WHERE last_seen > ${newestSeen}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWordRollupTriggers(db: DatabaseSync): void {
|
||||
const dayForNew = localEpochDaySql('NEW.first_seen');
|
||||
const dayForOld = localEpochDaySql('OLD.first_seen');
|
||||
@@ -186,6 +209,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
||||
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
||||
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
||||
}
|
||||
repairStaleLastSeen(db);
|
||||
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
||||
db.exec(`
|
||||
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
||||
|
||||
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
|
||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||
}
|
||||
|
||||
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
|
||||
// them, or comparisons against stored extremes miss and refreshes drift.
|
||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||
if (ms === null || !Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
return ms / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,8 +407,8 @@ function applyRemovalsForEntity(
|
||||
}
|
||||
updateAggregatesStmt.run(
|
||||
nextFrequency,
|
||||
Math.floor(Number(minSeenMs) / 1000),
|
||||
Math.floor(Number(maxSeenMs) / 1000),
|
||||
toStoredSeenSeconds(Number(minSeenMs)),
|
||||
toStoredSeenSeconds(Number(maxSeenMs)),
|
||||
removal.id,
|
||||
);
|
||||
continue;
|
||||
@@ -471,8 +473,8 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.wordId,
|
||||
);
|
||||
}
|
||||
@@ -527,8 +529,8 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.kanjiId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
|
||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
||||
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
||||
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
||||
vocabulary_visible = 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
||||
frequency_rank = CASE
|
||||
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_words
|
||||
wordSelectStmt: db.prepare(`
|
||||
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
|
||||
pos1, pos2, pos3, frequency_rank AS frequencyRank,
|
||||
vocabulary_visible AS vocabularyVisible
|
||||
FROM imm_words
|
||||
WHERE headword = ? AND word = ? AND reading = ?
|
||||
`),
|
||||
wordVisibilityUpdateStmt: db.prepare(`
|
||||
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
|
||||
`),
|
||||
kanjiIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_kanji
|
||||
WHERE kanji = ?
|
||||
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
|
||||
occurrence.frequencyRank ?? null,
|
||||
);
|
||||
}
|
||||
const row = stmts.wordIdSelectStmt.get(
|
||||
occurrence.headword,
|
||||
occurrence.word,
|
||||
occurrence.reading,
|
||||
) as { id: number } | null;
|
||||
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
|
||||
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
|
||||
| null;
|
||||
if (!row?.id) {
|
||||
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
||||
}
|
||||
// The upsert keeps the first-seen POS metadata, so derive visibility from the
|
||||
// stored row (as the vocabulary summary does) rather than the incoming token.
|
||||
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
|
||||
if (row.vocabularyVisible !== visible) {
|
||||
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
||||
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
||||
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
||||
assert.equal(cardsMined, 0);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
const osd: string[] = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ['one', 'two'],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => true,
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: () => {},
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
|
||||
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined?: (count: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
deps.logError('mineSentenceMultiple failed:', err);
|
||||
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -5497,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
},
|
||||
});
|
||||
const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||
buildMineSentenceCardMainDepsHandler(),
|
||||
@@ -5533,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
onCardsMined: (cards) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(cards);
|
||||
},
|
||||
handleMineSentenceDigitCore,
|
||||
});
|
||||
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
|
||||
|
||||
@@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
@@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
mpvClient: { connected: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
mine.recordCardsMined(1);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:x',
|
||||
'update',
|
||||
'fg:fg',
|
||||
'trigger',
|
||||
'audio:a',
|
||||
'mark',
|
||||
'mine:m',
|
||||
'cards:1',
|
||||
]);
|
||||
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
@@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
});
|
||||
}
|
||||
@@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => {
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
||||
test('mine sentence handler leaves card counting to card creation', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
@@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
@@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a
|
||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||
return true;
|
||||
},
|
||||
recordCardsMined: () => {},
|
||||
});
|
||||
|
||||
await mineSentenceCard();
|
||||
|
||||
@@ -83,18 +83,15 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
return async (): Promise<void> => {
|
||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
deps.recordCardsMined(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => {
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
|
||||
})();
|
||||
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
|
||||
mineDigit.showMpvOsd('done');
|
||||
mineDigit.logError('bad', null);
|
||||
mineDigit.onCardsMined(2);
|
||||
mineDigit.handleMineSentenceDigitCore(2, {
|
||||
subtitleTimingTracker: { track: true },
|
||||
ankiIntegration: { enabled: true },
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
onCardsMined: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
@@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => {
|
||||
'copy-current',
|
||||
'mine-osd:done',
|
||||
'err:bad',
|
||||
'cards:2',
|
||||
'mine-digit',
|
||||
]);
|
||||
});
|
||||
@@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
onCardsMined: (count: number) => deps.onCardsMined(count),
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => deps.handleMineSentenceDigitCore(count, options),
|
||||
});
|
||||
|
||||
@@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
getCurrentSecondarySubText: () => 'secondary',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: (count, options) => {
|
||||
assert.equal(count, 4);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
@@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||
options.showMpvOsd('mine');
|
||||
options.logError('boom', new Error('x'));
|
||||
options.onCardsMined(2);
|
||||
},
|
||||
});
|
||||
|
||||
handleMineSentenceDigit(4);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom']);
|
||||
});
|
||||
@@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
logError: deps.logError,
|
||||
onCardsMined: deps.onCardsMined,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user