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 {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
findAppBinary,
|
findAppBinary,
|
||||||
|
launchAppCommandDetached,
|
||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
parseMpvArgString,
|
parseMpvArgString,
|
||||||
runAppCommandCaptureOutput,
|
runAppCommandCaptureOutput,
|
||||||
@@ -109,6 +110,26 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
|
|||||||
assert.equal(error.code, 1);
|
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', () => {
|
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
|
||||||
const originalWrite = process.stdout.write;
|
const originalWrite = process.stdout.write;
|
||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
|
|||||||
@@ -958,6 +958,9 @@ export function launchAppCommandDetached(
|
|||||||
detached: true,
|
detached: true,
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||||
|
});
|
||||||
proc.unref();
|
proc.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,9 @@ export class AnkiIntegration {
|
|||||||
trackLastAddedNoteId: (noteId) => {
|
trackLastAddedNoteId: (noteId) => {
|
||||||
this.previousNoteIds.add(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;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardCreationService {
|
export class CardCreationService {
|
||||||
@@ -551,6 +552,7 @@ export class CardCreationService {
|
|||||||
);
|
);
|
||||||
log.info('Created sentence card:', noteId);
|
log.info('Created sentence card:', noteId);
|
||||||
this.deps.trackLastAddedNoteId?.(noteId);
|
this.deps.trackLastAddedNoteId?.(noteId);
|
||||||
|
this.deps.recordCardsMinedCallback?.(1, [noteId]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to create sentence card:', (error as Error).message);
|
log.error('Failed to create sentence card:', (error as Error).message);
|
||||||
this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
|
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', () => {
|
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
knownWords: {
|
knownWords: {
|
||||||
|
|||||||
@@ -178,7 +178,12 @@ export class KnownWordCacheManager {
|
|||||||
this.knownWordsStateKey = currentStateKey;
|
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);
|
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return;
|
return;
|
||||||
@@ -308,6 +313,44 @@ export class KnownWordCacheManager {
|
|||||||
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
|
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[] {
|
private getKnownWordQueryScopes(): KnownWordQueryScope[] {
|
||||||
const configuredDecks = this.deps.getConfig().knownWords?.decks;
|
const configuredDecks = this.deps.getConfig().knownWords?.decks;
|
||||||
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
|
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user