Files
toon/packages/cli/test/json-from-events.test.ts
2025-11-22 08:53:47 +01:00

424 lines
17 KiB
TypeScript

import type { JsonStreamEvent } from '../../toon/src/types'
import { describe, expect, it } from 'vitest'
import { jsonStreamFromEvents } from '../src/json-from-events'
describe('jsonStreamFromEvents', () => {
describe('primitives', () => {
it('converts null event', async () => {
const events = [
{ type: 'primitive' as const, value: null },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(null))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(null, null, 2))
})
it('converts boolean events', async () => {
const eventsTrue = [{ type: 'primitive' as const, value: true }]
const eventsFalse = [{ type: 'primitive' as const, value: false }]
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 0))).toBe(JSON.stringify(true))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFalse), 0))).toBe(JSON.stringify(false))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 2))).toBe(JSON.stringify(true, null, 2))
})
it('converts number events', async () => {
const events0 = [{ type: 'primitive' as const, value: 0 }]
const events42 = [{ type: 'primitive' as const, value: 42 }]
const eventsNeg = [{ type: 'primitive' as const, value: -17 }]
const eventsFloat = [{ type: 'primitive' as const, value: 3.14159 }]
expect(await join(jsonStreamFromEvents(asyncEvents(events0), 0))).toBe(JSON.stringify(0))
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 0))).toBe(JSON.stringify(42))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsNeg), 0))).toBe(JSON.stringify(-17))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFloat), 0))).toBe(JSON.stringify(3.14159))
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 2))).toBe(JSON.stringify(42, null, 2))
})
it('converts string events', async () => {
const eventsEmpty = [{ type: 'primitive' as const, value: '' }]
const eventsHello = [{ type: 'primitive' as const, value: 'hello' }]
const eventsQuotes = [{ type: 'primitive' as const, value: 'with "quotes"' }]
expect(await join(jsonStreamFromEvents(asyncEvents(eventsEmpty), 0))).toBe(JSON.stringify(''))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsHello), 0))).toBe(JSON.stringify('hello'))
expect(await join(jsonStreamFromEvents(asyncEvents(eventsQuotes), 0))).toBe(JSON.stringify('with "quotes"'))
})
})
describe('empty containers', () => {
it('converts empty array events', async () => {
const events = [
{ type: 'startArray' as const, length: 0 },
{ type: 'endArray' as const },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify([], null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify([], null, 2))
})
it('converts empty object events', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'endObject' as const },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify({}, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify({}, null, 2))
})
})
describe('arrays', () => {
it('converts simple array events with compact formatting', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
]
const value = [1, 2, 3]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('converts simple array events with pretty formatting', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
]
const value = [1, 2, 3]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts mixed-type array events', async () => {
const events = [
{ type: 'startArray' as const, length: 5 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 'two' },
{ type: 'primitive' as const, value: true },
{ type: 'primitive' as const, value: null },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'key' },
{ type: 'primitive' as const, value: 'value' },
{ type: 'endObject' as const },
{ type: 'endArray' as const },
]
const value = [1, 'two', true, null, { key: 'value' }]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts nested array events', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'endArray' as const },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'primitive' as const, value: 4 },
{ type: 'endArray' as const },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 5 },
{ type: 'primitive' as const, value: 6 },
{ type: 'endArray' as const },
{ type: 'endArray' as const },
]
const value = [[1, 2], [3, 4], [5, 6]]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('objects', () => {
it('converts simple object events with compact formatting', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'b' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
]
const value = { a: 1, b: 2, c: 3 }
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('converts simple object events with pretty formatting', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'b' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
]
const value = { a: 1, b: 2, c: 3 }
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts object events with mixed value types', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'num' },
{ type: 'primitive' as const, value: 42 },
{ type: 'key' as const, key: 'str' },
{ type: 'primitive' as const, value: 'hello' },
{ type: 'key' as const, key: 'bool' },
{ type: 'primitive' as const, value: true },
{ type: 'key' as const, key: 'nil' },
{ type: 'primitive' as const, value: null },
{ type: 'key' as const, key: 'arr' },
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'primitive' as const, value: 3 },
{ type: 'endArray' as const },
{ type: 'endObject' as const },
]
const value = {
num: 42,
str: 'hello',
bool: true,
nil: null,
arr: [1, 2, 3],
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts nested object events', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level1' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level2' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'level3' },
{ type: 'primitive' as const, value: 'deep' },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = {
level1: {
level2: {
level3: 'deep',
},
},
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles special characters in keys', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'normal-key' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'key with spaces' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'key:with:colons' },
{ type: 'primitive' as const, value: 3 },
{ type: 'key' as const, key: 'key"with"quotes' },
{ type: 'primitive' as const, value: 4 },
{ type: 'endObject' as const },
]
const value = {
'normal-key': 1,
'key with spaces': 2,
'key:with:colons': 3,
'key"with"quotes': 4,
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('complex nested structures', () => {
it('converts object containing arrays', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
{ type: 'key' as const, key: 'scores' },
{ type: 'startArray' as const, length: 3 },
{ type: 'primitive' as const, value: 95 },
{ type: 'primitive' as const, value: 87 },
{ type: 'primitive' as const, value: 92 },
{ type: 'endArray' as const },
{ type: 'key' as const, key: 'metadata' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'tags' },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 'math' },
{ type: 'primitive' as const, value: 'science' },
{ type: 'endArray' as const },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = {
name: 'Alice',
scores: [95, 87, 92],
metadata: {
tags: ['math', 'science'],
},
}
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('converts array of objects', async () => {
const events = [
{ type: 'startArray' as const, length: 3 },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 1 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
{ type: 'endObject' as const },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 2 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Bob' },
{ type: 'endObject' as const },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'id' },
{ type: 'primitive' as const, value: 3 },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Charlie' },
{ type: 'endObject' as const },
{ type: 'endArray' as const },
]
const value = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
})
describe('indentation levels', () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'a' },
{ type: 'startArray' as const, length: 2 },
{ type: 'primitive' as const, value: 1 },
{ type: 'primitive' as const, value: 2 },
{ type: 'endArray' as const },
{ type: 'key' as const, key: 'b' },
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'c' },
{ type: 'primitive' as const, value: 3 },
{ type: 'endObject' as const },
{ type: 'endObject' as const },
]
const value = { a: [1, 2], b: { c: 3 } }
it('handles indent=0 (compact)', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
})
it('handles indent=2', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
})
it('handles indent=4', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 4))).toBe(JSON.stringify(value, null, 4))
})
it('handles indent=8', async () => {
expect(await join(jsonStreamFromEvents(asyncEvents(events), 8))).toBe(JSON.stringify(value, null, 8))
})
})
describe('error handling', () => {
it('throws on mismatched endObject event', async () => {
const events = [
{ type: 'startArray' as const, length: 0 },
{ type: 'endObject' as const }, // Wrong closing event
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Mismatched endObject event')
})
it('throws on mismatched endArray event', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'endArray' as const }, // Wrong closing event
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Mismatched endArray event')
})
it('throws on key event outside object context', async () => {
const events = [
{ type: 'key' as const, key: 'invalid' },
{ type: 'primitive' as const, value: 1 },
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Key event outside of object context')
})
it('throws on primitive in object without preceding key', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'primitive' as const, value: 'invalid' }, // No key before primitive
{ type: 'endObject' as const },
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Primitive event in object without preceding key')
})
it('throws on incomplete event stream', async () => {
const events = [
{ type: 'startObject' as const },
{ type: 'key' as const, key: 'name' },
{ type: 'primitive' as const, value: 'Alice' },
// Missing `endObject`
]
await expect(async () => {
await join(jsonStreamFromEvents(asyncEvents(events), 0))
}).rejects.toThrow('Incomplete event stream: unclosed objects or arrays')
})
})
})
/**
* Converts array of events to async iterable.
*/
async function* asyncEvents(events: JsonStreamEvent[]): AsyncIterable<JsonStreamEvent> {
for (const event of events) {
await Promise.resolve()
yield event
}
}
/**
* Joins chunks from an async iterable into a single string.
*/
async function join(iter: AsyncIterable<string>): Promise<string> {
const chunks: string[] = []
for await (const chunk of iter) {
chunks.push(chunk)
}
return chunks.join('')
}