diff --git a/src/core/naming.ts b/src/core/naming.ts index d7e750a..91ddefb 100644 --- a/src/core/naming.ts +++ b/src/core/naming.ts @@ -24,6 +24,13 @@ export function extFromMime(mime: string): string { return EXT[base] ?? 'bin'; } +// Обратное к extFromMime; .bin и неизвестные расширения → null. +export function mimeFromExt(ext: string): string | null { + const lower = ext.toLowerCase(); + for (const [mime, e] of Object.entries(EXT)) if (e === lower) return mime; + return null; +} + export function audioPath(id: string, mime: string): string { return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; } diff --git a/src/core/provider-check.ts b/src/core/provider-check.ts new file mode 100644 index 0000000..57344fa --- /dev/null +++ b/src/core/provider-check.ts @@ -0,0 +1,36 @@ +import { mimeFromExt, SOURCE_DIR } from './naming'; +import type { TranscribeResult } from './ports'; + +// Команда «Проверить провайдер распознавания»: только уведомление, +// ни заметки, ни очередь не трогаются. + +export const NO_CHECK_AUDIO = `Нет записей в ${SOURCE_DIR}/ для проверки провайдера`; + +const PREVIEW_LIMIT = 100; + +// Последний по имени файл прямо в voice/source/: имена начинаются с UTC-времени, +// значит это самая свежая запись. +export function pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null { + const prefix = `${SOURCE_DIR}/`; + let best: { path: string; mime: string } | null = null; + for (const path of paths) { + if (!path.startsWith(prefix)) continue; + const name = path.slice(prefix.length); + const dot = name.lastIndexOf('.'); + if (name.includes('/') || dot < 0) continue; + const mime = mimeFromExt(name.slice(dot + 1)); + if (mime && (!best || path > best.path)) best = { path, mime }; + } + return best; +} + +export function checkNotice(result: TranscribeResult): string { + if (result.ok) { + const text = result.text.trim(); + if (!text) return 'Провайдер работает, но речь в записи не распознана'; + const preview = text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT)}…` : text; + return `Провайдер работает: «${preview}»`; + } + const kind = result.kind === 'temporary' ? 'временная' : 'постоянная'; + return `Провайдер: ${kind} ошибка — ${result.message}`; +} diff --git a/tests/naming.test.ts b/tests/naming.test.ts index 8315bf7..15d97cf 100644 --- a/tests/naming.test.ts +++ b/tests/naming.test.ts @@ -5,6 +5,7 @@ import { formatDuration, formatLocalTime, makeId, + mimeFromExt, notePath, } from '../src/core/naming'; @@ -77,3 +78,18 @@ describe('formatting', () => { expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); }); }); + +describe('mimeFromExt', () => { + it.each([ + ['m4a', 'audio/mp4'], + ['M4A', 'audio/mp4'], + ['webm', 'audio/webm'], + ['ogg', 'audio/ogg'], + ])('%s → %s', (ext, mime) => { + expect(mimeFromExt(ext)).toBe(mime); + }); + + it.each(['bin', 'mp3', '', 'md'])('%j → null', (ext) => { + expect(mimeFromExt(ext)).toBeNull(); + }); +}); diff --git a/tests/provider-check.test.ts b/tests/provider-check.test.ts new file mode 100644 index 0000000..3c3cb10 --- /dev/null +++ b/tests/provider-check.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { checkNotice, pickCheckAudio } from '../src/core/provider-check'; + +describe('pickCheckAudio', () => { + it('picks the last audio file by name directly in voice/source/', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260927-120000-abcd.webm', + 'voice/source/20260926-235959-ffff.ogg', + 'voice/inbox/2026-09-27 15-00-00.md', + ]), + ).toEqual({ path: 'voice/source/20260927-120000-abcd.webm', mime: 'audio/webm' }); + }); + + it('ignores .bin, unknown extensions, subfolders and look-alike folders', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260928-000000-0000.bin', + 'voice/source/20260928-000000-0001.mp3', + 'voice/source/old/20260929-000000-0000.m4a', + 'voice/sources/20260929-000000-0000.m4a', + 'voice/source/noext', + ]), + ).toEqual({ path: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4' }); + }); + + it('returns null when nothing fits', () => { + expect(pickCheckAudio([])).toBeNull(); + expect(pickCheckAudio(['voice/source/a.bin', 'notes/x.m4a'])).toBeNull(); + }); +}); + +describe('checkNotice', () => { + it('shows the start of the text', () => { + expect(checkNotice({ ok: true, text: ' Привет, мир ' })).toBe('Провайдер работает: «Привет, мир»'); + }); + + it('cuts long text to 100 chars with an ellipsis', () => { + expect(checkNotice({ ok: true, text: 'а'.repeat(150) })).toBe(`Провайдер работает: «${'а'.repeat(100)}…»`); + }); + + it('reports no speech for empty text', () => { + expect(checkNotice({ ok: true, text: ' ' })).toBe('Провайдер работает, но речь в записи не распознана'); + }); + + it('shows kind and reason of an error', () => { + expect(checkNotice({ ok: false, kind: 'temporary', message: 'HTTP 429' })).toBe( + 'Провайдер: временная ошибка — HTTP 429', + ); + expect(checkNotice({ ok: false, kind: 'permanent', message: 'HTTP 401: Invalid API key' })).toBe( + 'Провайдер: постоянная ошибка — HTTP 401: Invalid API key', + ); + }); +});