mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 19:21:34 -07:00
fix(stats): fallback to configured field when note info missing; honor A
- use configured audio field directly when note info is absent instead of null - use configured AnkiConnect URL in notesInfo route instead of hardcoded default - use shouldForceOverrideYomitanAnkiServer instead of forceOverride: true in addYomitanNote - add test for audio field fallback and Yomitan override policy
This commit is contained in:
@@ -354,6 +354,7 @@ type CapturedAnkiRequest = {
|
|||||||
|
|
||||||
async function withFakeAnkiConnect<T>(
|
async function withFakeAnkiConnect<T>(
|
||||||
fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>,
|
fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>,
|
||||||
|
options?: { notesInfoFields?: Record<string, { value: string }> | null },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const requests: CapturedAnkiRequest[] = [];
|
const requests: CapturedAnkiRequest[] = [];
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
@@ -373,18 +374,21 @@ async function withFakeAnkiConnect<T>(
|
|||||||
} else if (payload.action === 'notesInfo') {
|
} else if (payload.action === 'notesInfo') {
|
||||||
const noteIds = payload.params?.notes ?? [];
|
const noteIds = payload.params?.notes ?? [];
|
||||||
body = {
|
body = {
|
||||||
result: noteIds.map((noteId) => ({
|
result:
|
||||||
noteId,
|
options?.notesInfoFields === null
|
||||||
fields: {
|
? []
|
||||||
Expression: { value: '猫' },
|
: noteIds.map((noteId) => ({
|
||||||
ExpressionAudio: { value: '[sound:word.mp3]' },
|
noteId,
|
||||||
Sentence: { value: '' },
|
fields: options?.notesInfoFields ?? {
|
||||||
SentenceAudio: { value: '' },
|
Expression: { value: '猫' },
|
||||||
Picture: { value: '' },
|
ExpressionAudio: { value: '[sound:word.mp3]' },
|
||||||
MiscInfo: { value: '' },
|
Sentence: { value: '' },
|
||||||
SelectionText: { value: '' },
|
SentenceAudio: { value: '' },
|
||||||
},
|
Picture: { value: '' },
|
||||||
})),
|
MiscInfo: { value: '' },
|
||||||
|
SelectionText: { value: '' },
|
||||||
|
},
|
||||||
|
})),
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} else if (payload.action === 'findCards') {
|
} else if (payload.action === 'findCards') {
|
||||||
@@ -1771,6 +1775,65 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
audio: 'Voice',
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isLapis: {
|
||||||
|
enabled: true,
|
||||||
|
sentenceCardModel: 'Lapis Morph',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=audio', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
assert.match(
|
||||||
|
updateRequest?.params?.note?.fields?.Voice ?? '',
|
||||||
|
/^\[sound:subminer_audio_\d+\.mp3\]$/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ notesInfoFields: null },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
|
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -1989,9 +2052,12 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
|
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const requests: unknown[] = [];
|
const requests: Array<{ input: string; body: unknown }> = [];
|
||||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
requests.push(init?.body ? JSON.parse(String(init.body)) : null);
|
requests.push({
|
||||||
|
input: String(input),
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||||
|
});
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
result: [
|
result: [
|
||||||
@@ -2012,6 +2078,7 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const app = createStatsApp(createMockTracker(), {
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
ankiConnectConfig: { url: 'http://127.0.0.1:9876' },
|
||||||
resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId),
|
resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId),
|
||||||
});
|
});
|
||||||
const res = await app.request('/api/stats/anki/notesInfo', {
|
const res = await app.request('/api/stats/anki/notesInfo', {
|
||||||
@@ -2023,9 +2090,12 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(requests, [
|
assert.deepEqual(requests, [
|
||||||
{
|
{
|
||||||
action: 'notesInfo',
|
input: 'http://127.0.0.1:9876',
|
||||||
version: 6,
|
body: {
|
||||||
params: { notes: [222] },
|
action: 'notesInfo',
|
||||||
|
version: 6,
|
||||||
|
params: { notes: [222] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(await res.json(), [
|
assert.deepEqual(await res.json(), [
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function getStatsDirectMiningAudioFieldNames(
|
|||||||
: 'SentenceAudio';
|
: 'SentenceAudio';
|
||||||
const expressionAudioField = noteInfo
|
const expressionAudioField = noteInfo
|
||||||
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
||||||
: null;
|
: configuredAudioField;
|
||||||
|
|
||||||
if (mode === 'sentence') {
|
if (mode === 'sentence') {
|
||||||
return uniqueFieldNames(sentenceAudioField);
|
return uniqueFieldNames(sentenceAudioField);
|
||||||
@@ -1045,7 +1045,8 @@ export function createStatsApp(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:8765', {
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
|
|||||||
+4
-3
@@ -4476,10 +4476,11 @@ const startLocalStatsServer = (): void => {
|
|||||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||||
resolveSentenceSearchHeadwords,
|
resolveSentenceSearchHeadwords,
|
||||||
addYomitanNote: async (word: string) => {
|
addYomitanNote: async (word: string) => {
|
||||||
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
||||||
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
forceOverride: true,
|
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||||
deck: getResolvedConfig().ankiConnect.deck,
|
deck: ankiConnectConfig.deck,
|
||||||
});
|
});
|
||||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
|
|||||||
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
|
|||||||
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const startStatsServerBlock = source.match(
|
||||||
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(addYomitanNoteBlock);
|
||||||
|
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
||||||
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
|
});
|
||||||
|
|
||||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
|
|||||||
Reference in New Issue
Block a user