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>
171 lines
6.1 KiB
TypeScript
171 lines
6.1 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();
|
|
});
|
|
});
|
|
|
|
describe('CRLF line endings', () => {
|
|
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
|
|
|
|
it('readVoiceFields finds the block and strips \\r from values', () => {
|
|
expect(readVoiceFields(crlf('---\nvoice-id: "a"\nvoice-attempts: 2\n---\nbody\n'))).toEqual({
|
|
'voice-id': 'a',
|
|
'voice-attempts': '2',
|
|
});
|
|
});
|
|
|
|
it('patchFrontmatter edits the existing block and keeps CRLF', () => {
|
|
const content = crlf('---\ntags: [idea]\nvoice-status: "saved"\n---\nbody\n');
|
|
expect(patchFrontmatter(content, { 'voice-status': '"sent"', 'voice-attempts': '1' })).toBe(
|
|
crlf('---\ntags: [idea]\nvoice-status: "sent"\nvoice-attempts: 1\n---\nbody\n'),
|
|
);
|
|
});
|
|
|
|
it('creates a block with CRLF when the note has none', () => {
|
|
expect(patchFrontmatter(crlf('body\n'), { 'voice-id': '"a"' })).toBe(crlf('---\nvoice-id: "a"\n---\nbody\n'));
|
|
});
|
|
|
|
it('a mixed file gets one block, written in CRLF', () => {
|
|
const content = '---\r\nvoice-status: "saved"\n---\r\nbody\n';
|
|
expect(patchFrontmatter(content, { 'voice-status': '"sent"' })).toBe(crlf('---\nvoice-status: "sent"\n---\nbody\n'));
|
|
});
|
|
|
|
it('round-trips through readVoiceFields + parseRecord', () => {
|
|
const content = crlf(patchFrontmatter('', recordToFields(rec)));
|
|
expect(parseRecord(readVoiceFields(content))).toEqual(rec);
|
|
});
|
|
});
|