mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(stats): address review follow-ups
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,9 @@ export class AnkiIntegration {
|
||||
trackLastAddedNoteId: (noteId) => {
|
||||
this.previousNoteIds.add(noteId);
|
||||
},
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedCallback?.(count, noteIds);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
87
src/anki-integration/card-creation.test.ts
Normal file
87
src/anki-integration/card-creation.test.ts
Normal file
@@ -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] }]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user