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(); }); });