obsidian-voice-note/tests/note-frontmatter.test.ts
Andrey Epifancev 83236c4118 feat(core): read, parse and patch voice-* frontmatter
Refs #1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:57 +04:00

139 lines
4.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { parseRecord, patchFrontmatter, readVoiceFields, recordToFields } from '../src/core/note';
import type { VoiceRecord } from '../src/core/record';
const rec: VoiceRecord = {
id: '20260927-111503-0000',
status: 'retry',
device: 'd1',
audio: 'voice/source/20260927-111503-0000.m4a',
mime: 'audio/webm;codecs=opus',
attempts: 2,
nextAttempt: '2026-09-27T11:17:03.000Z',
error: 'timeout',
interrupted: true,
};
describe('recordToFields + patchFrontmatter', () => {
it('writes keys in fixed order with quoted strings', () => {
expect(patchFrontmatter('', recordToFields(rec))).toBe(
[
'---',
'voice-id: "20260927-111503-0000"',
'voice-status: "retry"',
'voice-device: "d1"',
'voice-audio: "[[voice/source/20260927-111503-0000.m4a]]"',
'voice-mime: "audio/webm;codecs=opus"',
'voice-attempts: 2',
'voice-next-attempt: "2026-09-27T11:17:03.000Z"',
'voice-error: "timeout"',
'voice-interrupted: true',
'---',
'',
].join('\n'),
);
});
it('round-trips through readVoiceFields + parseRecord', () => {
const content = patchFrontmatter('', recordToFields(rec));
expect(parseRecord(readVoiceFields(content))).toEqual(rec);
});
it('round-trips hostile error text as a single line', () => {
const hostile = { ...rec, error: 'Error: "bad"\nline two: x \\ y — ошибка' };
const content = patchFrontmatter('', recordToFields(hostile));
const errorLines = content.split('\n').filter((l) => l.startsWith('voice-error:'));
expect(errorLines).toHaveLength(1);
expect(content.split('\n').filter((l) => l.startsWith('line two'))).toHaveLength(0);
expect(parseRecord(readVoiceFields(content))).toEqual(hostile);
});
});
describe('patchFrontmatter', () => {
const user = '---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\n---\nbody\n';
it('replaces in place and keeps user keys and order', () => {
expect(patchFrontmatter(user, { 'voice-status': '"inserted"' })).toBe(
'---\ntitle: Идея\nvoice-status: "inserted"\ntags: [idea]\n---\nbody\n',
);
});
it('appends new keys at the end of the block', () => {
expect(patchFrontmatter(user, { 'voice-sent-at': '"t"' })).toBe(
'---\ntitle: Идея\nvoice-status: "saved"\ntags: [idea]\nvoice-sent-at: "t"\n---\nbody\n',
);
});
it('removes keys set to undefined, ignores absent ones', () => {
expect(patchFrontmatter(user, { 'voice-status': undefined, 'voice-error': undefined })).toBe(
'---\ntitle: Идея\ntags: [idea]\n---\nbody\n',
);
});
it('creates a block when there is none, body untouched', () => {
expect(patchFrontmatter('body\n', { 'voice-id': '"x"' })).toBe('---\nvoice-id: "x"\n---\nbody\n');
});
it('does not match a longer key with the same prefix', () => {
expect(patchFrontmatter('---\nvoice-idx: 1\n---\n', { 'voice-id': '"x"' })).toBe(
'---\nvoice-idx: 1\nvoice-id: "x"\n---\n',
);
});
});
describe('readVoiceFields', () => {
it('reads only the frontmatter block', () => {
const content = '---\nvoice-id: "a"\nother: 1\n---\nvoice-id: "b"\n';
expect(readVoiceFields(content)).toEqual({ 'voice-id': 'a' });
});
it('returns {} without frontmatter', () => {
expect(readVoiceFields('voice-id: "a"\n')).toEqual({});
});
it('accepts plain and single-quoted values', () => {
expect(readVoiceFields("---\nvoice-id: abc\nvoice-mime: 'audio/mp4'\n---\n")).toEqual({
'voice-id': 'abc',
'voice-mime': 'audio/mp4',
});
});
});
describe('parseRecord', () => {
const typed = {
'voice-id': '20260927-111503-0000',
'voice-status': 'saved',
'voice-device': 'd1',
'voice-audio': '[[voice/source/20260927-111503-0000.m4a]]',
'voice-mime': 'audio/mp4',
'voice-attempts': 0,
};
it('accepts metadataCache-style typed values', () => {
expect(parseRecord({ ...typed, 'voice-attempts': 3, 'voice-interrupted': true })).toEqual({
id: '20260927-111503-0000',
status: 'saved',
device: 'd1',
audio: 'voice/source/20260927-111503-0000.m4a',
mime: 'audio/mp4',
attempts: 3,
interrupted: true,
});
});
it('defaults missing attempts to 0', () => {
const { 'voice-attempts': _omit, ...rest } = typed;
expect(parseRecord(rest)?.attempts).toBe(0);
});
it.each([
['missing id', { ...typed, 'voice-id': undefined }],
['bad status', { ...typed, 'voice-status': 'foo' }],
['negative attempts', { ...typed, 'voice-attempts': -1 }],
['non-numeric attempts', { ...typed, 'voice-attempts': 'abc' }],
['object audio', { ...typed, 'voice-audio': { a: 1 } }],
])('returns null for %s', (_name, fields) => {
expect(parseRecord(fields)).toBeNull();
});
});