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:
sudacode committed 2026-10-01 22:56:38 -07:00
1 parent a5d0b63792
commit 4677f5624b
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,
);
}
+20 -9
View File
@@ -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;
}
-35
View File
@@ -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 () => {
+1 -7
View File
@@ -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}`);
-8
View File
@@ -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),
});
}
+2 -4
View File
@@ -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();
+2 -5
View File
@@ -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),
});
+1 -3
View File
@@ -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']);
});
-3
View File
@@ -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,
});
};
}