From 544cd8aaa027065c4572166987446cefd76658b3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Mar 2026 22:55:46 -0700 Subject: [PATCH] fix(stats): address review follow-ups --- launcher/mpv.test.ts | 21 +++++ launcher/mpv.ts | 3 + src/anki-integration.ts | 3 + src/anki-integration/card-creation.test.ts | 87 +++++++++++++++++++ src/anki-integration/card-creation.ts | 2 + src/anki-integration/known-word-cache.test.ts | 32 +++++++ src/anki-integration/known-word-cache.ts | 45 +++++++++- 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/anki-integration/card-creation.test.ts diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 16dbe0d..a60dc66 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -9,6 +9,7 @@ import type { Args } from './types'; import { cleanupPlaybackSession, findAppBinary, + launchAppCommandDetached, launchTexthookerOnly, parseMpvArgString, runAppCommandCaptureOutput, @@ -109,6 +110,26 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () assert.equal(error.code, 1); }); +test('launchAppCommandDetached handles child process spawn errors', async () => { + let uncaughtError: Error | null = null; + const onUncaughtException = (error: Error) => { + uncaughtError = error; + }; + process.once('uncaughtException', onUncaughtException); + try { + launchAppCommandDetached( + '/definitely-missing-subminer-binary', + [], + makeArgs({ logLevel: 'warn' }).logLevel, + 'test', + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(uncaughtError, null); + } finally { + process.removeListener('uncaughtException', onUncaughtException); + } +}); + test('stopOverlay logs a warning when stop command cannot be spawned', () => { const originalWrite = process.stdout.write; const writes: string[] = []; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 1a1095d..fa945c4 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -958,6 +958,9 @@ export function launchAppCommandDetached( detached: true, env: buildAppEnv(), }); + proc.once('error', (error) => { + log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); + }); proc.unref(); } diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 8b30355..b57037c 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -344,6 +344,9 @@ export class AnkiIntegration { trackLastAddedNoteId: (noteId) => { this.previousNoteIds.add(noteId); }, + recordCardsMinedCallback: (count, noteIds) => { + this.recordCardsMinedCallback?.(count, noteIds); + }, }); } diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts new file mode 100644 index 0000000..81bdc71 --- /dev/null +++ b/src/anki-integration/card-creation.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { CardCreationService } from './card-creation'; +import type { AnkiConnectConfig } from '../types'; + +test('CardCreationService counts locally created sentence cards', async () => { + const minedCards: Array<{ count: number; noteIds?: number[] }> = []; + const service = new CardCreationService({ + getConfig: () => + ({ + deck: 'Mining', + fields: { + sentence: 'Sentence', + audio: 'SentenceAudio', + }, + media: { + generateAudio: false, + generateImage: false, + }, + behavior: {}, + ai: false, + }) as AnkiConnectConfig, + getAiConfig: () => ({}), + getTimingTracker: () => ({}) as never, + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentSubText: '字幕', + currentSubStart: 1, + currentSubEnd: 2, + currentTimePos: 1.5, + currentAudioStreamIndex: 0, + }) as never, + client: { + addNote: async () => 42, + addTags: async () => undefined, + notesInfo: async () => [], + updateNoteFields: async () => undefined, + storeMediaFile: async () => undefined, + findNotes: async () => [], + retrieveMediaFile: async () => '', + }, + mediaGenerator: { + generateAudio: async () => null, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + showOsdNotification: () => undefined, + showUpdateResult: () => undefined, + showStatusNotification: () => undefined, + showNotification: async () => undefined, + beginUpdateProgress: () => undefined, + endUpdateProgress: () => undefined, + withUpdateProgress: async (_message, action) => action(), + resolveConfiguredFieldName: () => null, + resolveNoteFieldName: () => null, + getAnimatedImageLeadInSeconds: async () => 0, + extractFields: () => ({}), + processSentence: (sentence) => sentence, + setCardTypeFields: () => undefined, + mergeFieldValue: (_existing, newValue) => newValue, + formatMiscInfoPattern: () => '', + getEffectiveSentenceCardConfig: () => ({ + model: 'Sentence', + sentenceField: 'Sentence', + audioField: 'SentenceAudio', + lapisEnabled: false, + kikuEnabled: false, + kikuFieldGrouping: 'disabled', + kikuDeleteDuplicateInAuto: false, + }), + getFallbackDurationSeconds: () => 10, + appendKnownWordsFromNoteInfo: () => undefined, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + trackLastAddedNoteId: () => undefined, + recordCardsMinedCallback: (count, noteIds) => { + minedCards.push({ count, noteIds }); + }, + }); + + const created = await service.createSentenceCard('テスト', 0, 1); + + assert.equal(created, true); + assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 1bb5670..c9c830b 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -110,6 +110,7 @@ interface CardCreationDeps { isUpdateInProgress: () => boolean; setUpdateInProgress: (value: boolean) => void; trackLastAddedNoteId?: (noteId: number) => void; + recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; } export class CardCreationService { @@ -551,6 +552,7 @@ export class CardCreationService { ); log.info('Created sentence card:', noteId); this.deps.trackLastAddedNoteId?.(noteId); + this.deps.recordCardsMinedCallback?.(1, [noteId]); } catch (error) { log.error('Failed to create sentence card:', (error as Error).message); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts index b286d30..48c9815 100644 --- a/src/anki-integration/known-word-cache.test.ts +++ b/src/anki-integration/known-word-cache.test.ts @@ -348,6 +348,38 @@ test('KnownWordCacheManager preserves deck-specific field mappings during refres } }); +test('KnownWordCacheManager uses the current deck fields for immediate append', () => { + const config: AnkiConnectConfig = { + deck: 'Mining', + fields: { + word: 'Word', + }, + knownWords: { + highlightEnabled: true, + decks: { + Mining: ['Expression'], + Reading: ['Word'], + }, + }, + }; + const { manager, cleanup } = createKnownWordCacheHarness(config); + + try { + manager.appendFromNoteInfo({ + noteId: 1, + fields: { + Expression: { value: '猫' }, + Word: { value: 'should-not-count' }, + }, + }); + + assert.equal(manager.isKnownWord('猫'), true); + assert.equal(manager.isKnownWord('should-not-count'), false); + } finally { + cleanup(); + } +}); + test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => { const config: AnkiConnectConfig = { knownWords: { diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index ee4dc60..07c55c3 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -178,7 +178,12 @@ export class KnownWordCacheManager { this.knownWordsStateKey = currentStateKey; } - const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo); + const preferredFields = this.getImmediateAppendFields(); + if (!preferredFields) { + return; + } + + const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields); const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords); if (!changed) { return; @@ -308,6 +313,44 @@ export class KnownWordCacheManager { return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])]; } + private getImmediateAppendFields(): string[] | null { + const configuredDecks = this.deps.getConfig().knownWords?.decks; + if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { + const trimmedDeckEntries = Object.entries(configuredDecks) + .map(([deckName, fields]) => [deckName.trim(), fields] as const) + .filter(([deckName]) => deckName.length > 0); + + const currentDeck = this.deps.getConfig().deck?.trim(); + const selectedDeckEntry = + currentDeck !== undefined && currentDeck.length > 0 + ? trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null + : trimmedDeckEntries.length === 1 + ? trimmedDeckEntries[0] ?? null + : null; + + if (!selectedDeckEntry) { + return null; + } + + const deckFields = selectedDeckEntry[1]; + if (Array.isArray(deckFields)) { + const normalizedFields = [ + ...new Set( + deckFields.map(String).map((field) => field.trim()).filter((field) => field.length > 0), + ), + ]; + if (normalizedFields.length > 0) { + return normalizedFields; + } + } + + const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig()); + return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])]; + } + + return this.getConfiguredFields(); + } + private getKnownWordQueryScopes(): KnownWordQueryScope[] { const configuredDecks = this.deps.getConfig().knownWords?.decks; if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {