readVoiceFields/patchFrontmatter split on \r?\n; patchFrontmatter and renderNote write in the note's own line-ending style. Before: a CRLF note's '---\r' was not recognised, the patch prepended a second frontmatter block and the record fell out of the queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.2 KiB
TypeScript
139 lines
6.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { initialNote, readVoiceFields, renderNote } from '../src/core/note';
|
||
import { NO_SPEECH, type VoiceRecord } from '../src/core/record';
|
||
|
||
const saved: VoiceRecord = {
|
||
id: '20260927-111503-0000',
|
||
status: 'saved',
|
||
device: 'd1',
|
||
audio: 'voice/source/20260927-111503-0000.m4a',
|
||
mime: 'audio/mp4',
|
||
attempts: 0,
|
||
};
|
||
const FM =
|
||
'---\n' +
|
||
'voice-id: "20260927-111503-0000"\n' +
|
||
'voice-status: "saved"\n' +
|
||
'voice-device: "d1"\n' +
|
||
'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"\n' +
|
||
'voice-mime: "audio/mp4"\n' +
|
||
'voice-attempts: 0\n' +
|
||
'---\n';
|
||
const REGION = '%%voice-note:20260927-111503-0000%%\n> [!info] Распознаётся…\n%%/voice-note%%\n';
|
||
|
||
const inserted: VoiceRecord = { ...saved, status: 'inserted' };
|
||
|
||
describe('initialNote', () => {
|
||
it('builds frontmatter, embed and status region', () => {
|
||
expect(initialNote(saved, 47_000)).toBe(`${FM}![[voice/source/20260927-111503-0000.m4a]]\n\n${REGION}`);
|
||
});
|
||
|
||
it('adds interrupted line above the region', () => {
|
||
const note = initialNote({ ...saved, interrupted: true }, 47_000);
|
||
expect(note).toContain('voice-interrupted: true\n---\n');
|
||
expect(note).toContain(
|
||
'![[voice/source/20260927-111503-0000.m4a]]\n\n> [!note] Запись прервана, сохранено 0:47\n\n%%voice-note:',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('renderNote: status region', () => {
|
||
it('retry shows local time and attempt', () => {
|
||
const r: VoiceRecord = { ...saved, status: 'retry', attempts: 2, nextAttempt: '2026-09-27T11:17:03.000Z', error: 't' };
|
||
const out = renderNote(initialNote(saved, 0), r);
|
||
expect(out).toContain('> [!info] Не удалось отправить, повтор в 14:17 (попытка 2/5)\n');
|
||
expect(out).not.toContain('Распознаётся');
|
||
expect(readVoiceFields(out)['voice-status']).toBe('retry');
|
||
});
|
||
|
||
it('failed shows reason and the command', () => {
|
||
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: '401 Unauthorized' });
|
||
expect(out).toContain('> [!warning] Не распознано: 401 Unauthorized. Команда «Повторить распознавание»\n');
|
||
});
|
||
|
||
it('no-speech is not an error', () => {
|
||
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: NO_SPEECH });
|
||
expect(out).toContain('> [!info] В записи не распознано речи\n');
|
||
expect(out).not.toMatch(/ошибк|Не распознано:|Повторить/);
|
||
});
|
||
|
||
it('collapses multi-line error into one callout line', () => {
|
||
const out = renderNote(initialNote(saved, 0), { ...saved, status: 'failed', attempts: 1, error: 'a\n\nb' });
|
||
expect(out).toContain('> [!warning] Не распознано: a b. Команда');
|
||
});
|
||
|
||
it('without markers updates frontmatter only', () => {
|
||
const content = `${FM}моя заметка\n`;
|
||
const out = renderNote(content, { ...saved, status: 'sent', sentAt: 'x' });
|
||
expect(out).toBe(content.replace('voice-status: "saved"', 'voice-status: "sent"').replace('voice-attempts: 0\n', 'voice-attempts: 0\nvoice-sent-at: "x"\n'));
|
||
});
|
||
});
|
||
|
||
describe('renderNote: transcript', () => {
|
||
it('replaces the region and keeps user text around it', () => {
|
||
const content = `${initialNote(saved, 0)}купить молоко\n`;
|
||
expect(renderNote(content, inserted, ' Привет, мир.\n')).toBe(
|
||
`${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nПривет, мир.\nкупить молоко\n`,
|
||
);
|
||
});
|
||
|
||
it('keeps the interrupted line', () => {
|
||
const r = { ...saved, interrupted: true };
|
||
const out = renderNote(initialNote(r, 47_000), { ...r, status: 'inserted' }, 'текст');
|
||
expect(out).toContain('> [!note] Запись прервана, сохранено 0:47\n\nтекст\n');
|
||
});
|
||
|
||
it('appends at the end when markers were removed', () => {
|
||
const content = `${FM}заметка без маркеров\n\n\n`;
|
||
expect(renderNote(content, inserted, 'текст')).toBe(
|
||
`${FM.replace('"saved"', '"inserted"')}заметка без маркеров\n\nтекст\n`,
|
||
);
|
||
});
|
||
|
||
it('is a no-op when already inserted', () => {
|
||
const once = renderNote(initialNote(saved, 0), inserted, 'первый');
|
||
expect(renderNote(once, inserted, 'второй')).toBe(once);
|
||
});
|
||
|
||
it('inserts special replacement patterns verbatim', () => {
|
||
const text = "цена $& и $' и $1 и %%voice-note%% и ---";
|
||
expect(renderNote(initialNote(saved, 0), inserted, text)).toContain(`\n\n${text}\n`);
|
||
});
|
||
|
||
it('keeps user properties in place', () => {
|
||
const content = initialNote(saved, 0).replace('---\nvoice-id', '---\ntags: [idea]\nvoice-id');
|
||
const out = renderNote(content, inserted, 'x');
|
||
expect(out.startsWith('---\ntags: [idea]\nvoice-id: "20260927-111503-0000"\nvoice-status: "inserted"\n')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('renderNote: CRLF notes', () => {
|
||
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
|
||
|
||
it('updates the status region in place and keeps CRLF', () => {
|
||
const failed: VoiceRecord = { ...saved, status: 'failed', error: 'HTTP 401' };
|
||
const lf = `${initialNote(saved, 0)}после\n`;
|
||
expect(renderNote(crlf(lf), failed)).toBe(crlf(renderNote(lf, failed)));
|
||
});
|
||
|
||
it('inserts the transcript with CRLF, including multi-line text', () => {
|
||
const content = crlf(`${initialNote(saved, 0)}купить молоко\n`);
|
||
expect(renderNote(content, inserted, 'строка 1\nстрока 2')).toBe(
|
||
crlf(
|
||
`${FM.replace('"saved"', '"inserted"')}![[voice/source/20260927-111503-0000.m4a]]\n\nстрока 1\nстрока 2\nкупить молоко\n`,
|
||
),
|
||
);
|
||
});
|
||
|
||
it('appends at the end with CRLF when markers were removed', () => {
|
||
expect(renderNote(crlf(`${FM}без маркеров\n`), inserted, 'текст')).toBe(
|
||
crlf(`${FM.replace('"saved"', '"inserted"')}без маркеров\n\nтекст\n`),
|
||
);
|
||
});
|
||
|
||
it('is a no-op when already inserted', () => {
|
||
const once = renderNote(crlf(initialNote(saved, 0)), inserted, 'первый');
|
||
expect(renderNote(once, inserted, 'второй')).toBe(once);
|
||
});
|
||
});
|