Files
toon/test/index.test.ts
2025-10-22 21:58:55 +02:00

618 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { encode } from '../src/index'
describe('primitives', () => {
it('encodes safe strings without quotes', () => {
expect(encode('hello')).toBe('hello')
expect(encode('Ada_99')).toBe('Ada_99')
})
it('quotes empty string', () => {
expect(encode('')).toBe('""')
})
it('quotes strings that look like booleans or numbers', () => {
expect(encode('true')).toBe('"true"')
expect(encode('false')).toBe('"false"')
expect(encode('null')).toBe('"null"')
expect(encode('42')).toBe('"42"')
expect(encode('-3.14')).toBe('"-3.14"')
expect(encode('1e-6')).toBe('"1e-6"')
expect(encode('05')).toBe('"05"')
})
it('escapes control characters in strings', () => {
expect(encode('line1\nline2')).toBe('"line1\\nline2"')
expect(encode('tab\there')).toBe('"tab\\there"')
expect(encode('return\rcarriage')).toBe('"return\\rcarriage"')
expect(encode('C:\\Users\\path')).toBe('"C:\\\\Users\\\\path"')
})
it('quotes strings with structural characters', () => {
expect(encode('[3]: x,y')).toBe('"[3]: x,y"')
expect(encode('- item')).toBe('"- item"')
expect(encode('[test]')).toBe('"[test]"')
expect(encode('{key}')).toBe('"{key}"')
})
it('handles Unicode and emoji', () => {
expect(encode('café')).toBe('café')
expect(encode('你好')).toBe('你好')
expect(encode('🚀')).toBe('🚀')
expect(encode('hello 👋 world')).toBe('hello 👋 world')
})
it('encodes numbers', () => {
expect(encode(42)).toBe('42')
expect(encode(3.14)).toBe('3.14')
expect(encode(-7)).toBe('-7')
expect(encode(0)).toBe('0')
})
it('handles special numeric values', () => {
expect(encode(-0)).toBe('0')
expect(encode(1e6)).toBe('1000000')
expect(encode(1e-6)).toBe('0.000001')
expect(encode(1e20)).toBe('100000000000000000000')
expect(encode(Number.MAX_SAFE_INTEGER)).toBe('9007199254740991')
})
it('encodes booleans', () => {
expect(encode(true)).toBe('true')
expect(encode(false)).toBe('false')
})
it('encodes null', () => {
expect(encode(null)).toBe('null')
})
})
describe('objects (simple)', () => {
it('preserves key order in objects', () => {
const obj = {
id: 123,
name: 'Ada',
active: true,
}
expect(encode(obj)).toBe('id: 123\nname: Ada\nactive: true')
})
it('encodes null values in objects', () => {
const obj = { id: 123, value: null }
expect(encode(obj)).toBe('id: 123\nvalue: null')
})
it('encodes empty objects as empty string', () => {
expect(encode({})).toBe('')
})
it('quotes string values with special characters', () => {
expect(encode({ note: 'a:b' })).toBe('note: "a:b"')
expect(encode({ note: 'a,b' })).toBe('note: "a,b"')
expect(encode({ text: 'line1\nline2' })).toBe('text: "line1\\nline2"')
expect(encode({ text: 'say "hello"' })).toBe('text: "say \\"hello\\""')
})
it('quotes string values with leading/trailing spaces', () => {
expect(encode({ text: ' padded ' })).toBe('text: " padded "')
expect(encode({ text: ' ' })).toBe('text: " "')
})
it('quotes string values that look like booleans/numbers', () => {
expect(encode({ v: 'true' })).toBe('v: "true"')
expect(encode({ v: '42' })).toBe('v: "42"')
expect(encode({ v: '-7.5' })).toBe('v: "-7.5"')
})
})
describe('objects (keys)', () => {
it('quotes keys with special characters', () => {
expect(encode({ 'order:id': 7 })).toBe('"order:id": 7')
expect(encode({ '[index]': 5 })).toBe('"[index]": 5')
expect(encode({ '{key}': 5 })).toBe('"{key}": 5')
expect(encode({ 'a,b': 1 })).toBe('"a,b": 1')
})
it('quotes keys with spaces or leading hyphens', () => {
expect(encode({ 'full name': 'Ada' })).toBe('"full name": Ada')
expect(encode({ '-lead': 1 })).toBe('"-lead": 1')
expect(encode({ ' a ': 1 })).toBe('" a ": 1')
})
it('quotes numeric keys', () => {
expect(encode({ 123: 'x' })).toBe('"123": x')
})
it('quotes empty string key', () => {
expect(encode({ '': 1 })).toBe('"": 1')
})
it('escapes control characters in keys', () => {
expect(encode({ 'line\nbreak': 1 })).toBe('"line\\nbreak": 1')
expect(encode({ 'tab\there': 2 })).toBe('"tab\\there": 2')
})
it('escapes quotes in keys', () => {
expect(encode({ 'he said "hi"': 1 })).toBe('"he said \\"hi\\"": 1')
})
})
describe('nested objects', () => {
it('encodes deeply nested objects', () => {
const obj = {
a: {
b: {
c: 'deep',
},
},
}
expect(encode(obj)).toBe('a:\n b:\n c: deep')
})
it('encodes empty nested object', () => {
expect(encode({ user: {} })).toBe('user:')
})
})
describe('arrays of primitives', () => {
it('encodes string arrays inline', () => {
const obj = { tags: ['admin', 'ops'] }
expect(encode(obj)).toBe('tags[2]: admin,ops')
})
it('encodes number arrays inline', () => {
const obj = { nums: [1, 2, 3] }
expect(encode(obj)).toBe('nums[3]: 1,2,3')
})
it('encodes mixed primitive arrays inline', () => {
const obj = { data: ['x', 'y', true, 10] }
expect(encode(obj)).toBe('data[4]: x,y,true,10')
})
it('encodes empty arrays', () => {
const obj = { items: [] }
expect(encode(obj)).toBe('items[0]:')
})
it('handles empty string in arrays', () => {
const obj = { items: [''] }
expect(encode(obj)).toBe('items[1]: ""')
const obj2 = { items: ['a', '', 'b'] }
expect(encode(obj2)).toBe('items[3]: a,"",b')
})
it('handles whitespace-only strings in arrays', () => {
const obj = { items: [' ', ' '] }
expect(encode(obj)).toBe('items[2]: " "," "')
})
it('quotes array strings with special characters', () => {
const obj = { items: ['a', 'b,c', 'd:e'] }
expect(encode(obj)).toBe('items[3]: a,"b,c","d:e"')
})
it('quotes strings that look like booleans/numbers in arrays', () => {
const obj = { items: ['x', 'true', '42', '-3.14'] }
expect(encode(obj)).toBe('items[4]: x,"true","42","-3.14"')
})
it('quotes strings with structural meanings in arrays', () => {
const obj = { items: ['[5]', '- item', '{key}'] }
expect(encode(obj)).toBe('items[3]: "[5]","- item","{key}"')
})
})
describe('arrays of objects (tabular and list items)', () => {
it('encodes arrays of similar objects in tabular format', () => {
const obj = {
items: [
{ sku: 'A1', qty: 2, price: 9.99 },
{ sku: 'B2', qty: 1, price: 14.5 },
],
}
expect(encode(obj)).toBe('items[2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5')
})
it('handles null values in tabular format', () => {
const obj = {
items: [
{ id: 1, value: null },
{ id: 2, value: 'test' },
],
}
expect(encode(obj)).toBe('items[2]{id,value}:\n 1,null\n 2,test')
})
it('quotes strings in tabular rows when needed', () => {
const obj = {
items: [
{ sku: 'A,1', desc: 'cool', qty: 2 },
{ sku: 'B2', desc: 'wip: test', qty: 1 },
],
}
expect(encode(obj)).toBe('items[2]{sku,desc,qty}:\n "A,1",cool,2\n B2,"wip: test",1')
})
it('quotes ambiguous strings in tabular rows', () => {
const obj = {
items: [
{ id: 1, status: 'true' },
{ id: 2, status: 'false' },
],
}
expect(encode(obj)).toBe('items[2]{id,status}:\n 1,"true"\n 2,"false"')
})
it('handles tabular arrays with keys needing quotes', () => {
const obj = {
items: [
{ 'order:id': 1, 'full name': 'Ada' },
{ 'order:id': 2, 'full name': 'Bob' },
],
}
expect(encode(obj)).toBe('items[2]{"order:id","full name"}:\n 1,Ada\n 2,Bob')
})
it('uses list format for objects with different fields', () => {
const obj = {
items: [
{ id: 1, name: 'First' },
{ id: 2, name: 'Second', extra: true },
],
}
expect(encode(obj)).toBe(
'items[2]:\n'
+ ' - id: 1\n'
+ ' name: First\n'
+ ' - id: 2\n'
+ ' name: Second\n'
+ ' extra: true',
)
})
it('uses list format for objects with nested values', () => {
const obj = {
items: [
{ id: 1, nested: { x: 1 } },
],
}
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - id: 1\n'
+ ' nested:\n'
+ ' x: 1',
)
})
it('uses field order from first object for tabular headers', () => {
const obj = {
items: [
{ a: 1, b: 2, c: 3 },
{ c: 30, b: 20, a: 10 },
],
}
expect(encode(obj)).toBe('items[2]{a,b,c}:\n 1,2,3\n 10,20,30')
})
it('uses list format for one object with nested column', () => {
const obj = {
items: [
{ id: 1, data: 'string' },
{ id: 2, data: { nested: true } },
],
}
expect(encode(obj)).toBe(
'items[2]:\n'
+ ' - id: 1\n'
+ ' data: string\n'
+ ' - id: 2\n'
+ ' data:\n'
+ ' nested: true',
)
})
})
describe('arrays of arrays (primitives only)', () => {
it('encodes nested arrays of primitives', () => {
const obj = {
pairs: [['a', 'b'], ['c', 'd']],
}
expect(encode(obj)).toBe('pairs[2]:\n - [2]: a,b\n - [2]: c,d')
})
it('quotes nested array strings when needed', () => {
const obj = {
pairs: [['a', 'b'], ['c,d', 'e:f', 'true']],
}
expect(encode(obj)).toBe('pairs[2]:\n - [2]: a,b\n - [3]: "c,d","e:f","true"')
})
it('handles empty inner arrays', () => {
const obj = {
pairs: [[], []],
}
expect(encode(obj)).toBe('pairs[2]:\n - [0]:\n - [0]:')
})
it('handles mixed-length inner arrays', () => {
const obj = {
pairs: [[1], [2, 3]],
}
expect(encode(obj)).toBe('pairs[2]:\n - [1]: 1\n - [2]: 2,3')
})
})
describe('root arrays', () => {
it('encodes arrays of primitives at root level', () => {
const arr = ['x', 'y', 'true', true, 10]
expect(encode(arr)).toBe('[5]: x,y,"true",true,10')
})
it('encodes arrays of similar objects in tabular format', () => {
const arr = [{ id: 1 }, { id: 2 }]
expect(encode(arr)).toBe('[2]{id}:\n 1\n 2')
})
it('encodes arrays of different objects in list format', () => {
const arr = [{ id: 1 }, { id: 2, name: 'Ada' }]
expect(encode(arr)).toBe('[2]:\n - id: 1\n - id: 2\n name: Ada')
})
it('encodes empty arrays at root level', () => {
expect(encode([])).toBe('[0]:')
})
it('encodes root arrays of arrays', () => {
const arr = [[1, 2], []]
expect(encode(arr)).toBe('[2]:\n - [2]: 1,2\n - [0]:')
})
})
describe('complex structures', () => {
it('encodes objects with mixed arrays and nested objects', () => {
const obj = {
user: {
id: 123,
name: 'Ada',
tags: ['admin', 'ops'],
active: true,
prefs: [],
},
}
expect(encode(obj)).toBe(
'user:\n'
+ ' id: 123\n'
+ ' name: Ada\n'
+ ' tags[2]: admin,ops\n'
+ ' active: true\n'
+ ' prefs[0]:',
)
})
})
describe('mixed arrays', () => {
it('uses list format for arrays mixing primitives and objects', () => {
const obj = {
items: [1, { a: 1 }, 'text'],
}
expect(encode(obj)).toBe(
'items[3]:\n'
+ ' - 1\n'
+ ' - a: 1\n'
+ ' - text',
)
})
it('uses list format for arrays mixing objects and arrays', () => {
const obj = {
items: [{ a: 1 }, [1, 2]],
}
expect(encode(obj)).toBe(
'items[2]:\n'
+ ' - a: 1\n'
+ ' - [2]: 1,2',
)
})
})
describe('whitespace and formatting invariants', () => {
it('produces no trailing spaces at end of lines', () => {
const obj = {
user: {
id: 123,
name: 'Ada',
},
items: ['a', 'b'],
}
const result = encode(obj)
const lines = result.split('\n')
for (const line of lines) {
expect(line).not.toMatch(/ $/)
}
})
it('produces no trailing newline at end of output', () => {
const obj = { id: 123 }
const result = encode(obj)
expect(result).not.toMatch(/\n$/)
})
})
describe('non-JSON-serializable values', () => {
it('converts BigInt to string', () => {
expect(encode(BigInt(123))).toBe('123')
expect(encode({ id: BigInt(456) })).toBe('id: 456')
})
it('converts Date to ISO string', () => {
const date = new Date('2025-01-01T00:00:00.000Z')
expect(encode(date)).toBe('"2025-01-01T00:00:00.000Z"')
expect(encode({ created: date })).toBe('created: "2025-01-01T00:00:00.000Z"')
})
it('converts undefined to null', () => {
expect(encode(undefined)).toBe('null')
expect(encode({ value: undefined })).toBe('value: null')
})
it('converts non-finite numbers to null', () => {
expect(encode(Infinity)).toBe('null')
expect(encode(-Infinity)).toBe('null')
expect(encode(Number.NaN)).toBe('null')
})
it('converts functions to null', () => {
expect(encode(() => {})).toBe('null')
expect(encode({ fn: () => {} })).toBe('fn: null')
})
it('converts symbols to null', () => {
expect(encode(Symbol('test'))).toBe('null')
expect(encode({ sym: Symbol('test') })).toBe('sym: null')
})
})
describe('delimiter options', () => {
describe('basic delimiter usage', () => {
it.each([
{ delimiter: '\t' as const, name: 'tab', expected: 'admin\tops\tdev' },
{ delimiter: '|' as const, name: 'pipe', expected: 'admin|ops|dev' },
{ delimiter: ',' as const, name: 'comma', expected: 'admin,ops,dev' },
])('encodes primitive arrays with $name delimiter', ({ delimiter, expected }) => {
const obj = { tags: ['admin', 'ops', 'dev'] }
expect(encode(obj, { delimiter })).toBe(`tags[3]: ${expected}`)
})
it.each([
{ delimiter: '\t' as const, name: 'tab', expected: 'items[2]{sku,qty,price}:\n A1\t2\t9.99\n B2\t1\t14.5' },
{ delimiter: '|' as const, name: 'pipe', expected: 'items[2]{sku,qty,price}:\n A1|2|9.99\n B2|1|14.5' },
])('encodes tabular arrays with $name delimiter', ({ delimiter, expected }) => {
const obj = {
items: [
{ sku: 'A1', qty: 2, price: 9.99 },
{ sku: 'B2', qty: 1, price: 14.5 },
],
}
expect(encode(obj, { delimiter })).toBe(expected)
})
it.each([
{ delimiter: '\t' as const, name: 'tab', expected: 'pairs[2]:\n - [2]: a\tb\n - [2]: c\td' },
{ delimiter: '|' as const, name: 'pipe', expected: 'pairs[2]:\n - [2]: a|b\n - [2]: c|d' },
])('encodes nested arrays with $name delimiter', ({ delimiter, expected }) => {
const obj = { pairs: [['a', 'b'], ['c', 'd']] }
expect(encode(obj, { delimiter })).toBe(expected)
})
it.each([
{ delimiter: '\t' as const, name: 'tab' },
{ delimiter: '|' as const, name: 'pipe' },
])('encodes root arrays with $name delimiter', ({ delimiter }) => {
const arr = ['x', 'y', 'z']
expect(encode(arr, { delimiter })).toBe(`[3]: x${delimiter}y${delimiter}z`)
})
it.each([
{ delimiter: '\t' as const, name: 'tab', expected: '[2]{id}:\n 1\n 2' },
{ delimiter: '|' as const, name: 'pipe', expected: '[2]{id}:\n 1\n 2' },
])('encodes root arrays of objects with $name delimiter', ({ delimiter, expected }) => {
const arr = [{ id: 1 }, { id: 2 }]
expect(encode(arr, { delimiter })).toBe(expected)
})
})
describe('delimiter-aware quoting', () => {
it.each([
{ delimiter: '\t' as const, name: 'tab', char: '\t', input: ['a', 'b\tc', 'd'], expected: 'a\t"b\\tc"\td' },
{ delimiter: '|' as const, name: 'pipe', char: '|', input: ['a', 'b|c', 'd'], expected: 'a|"b|c"|d' },
])('quotes strings containing the active $name delimiter', ({ delimiter, input, expected }) => {
expect(encode({ items: input }, { delimiter })).toBe(`items[${input.length}]: ${expected}`)
})
it.each([
{ delimiter: '\t' as const, name: 'tab', input: ['a,b', 'c,d'], expected: 'a,b\tc,d' },
{ delimiter: '|' as const, name: 'pipe', input: ['a,b', 'c,d'], expected: 'a,b|c,d' },
])('does not quote commas when using $name delimiter', ({ delimiter, input, expected }) => {
expect(encode({ items: input }, { delimiter })).toBe(`items[${input.length}]: ${expected}`)
})
it('quotes values containing the active delimiter in tabular format', () => {
const obj = {
items: [
{ id: 1, note: 'a,b' },
{ id: 2, note: 'c,d' },
],
}
expect(encode(obj, { delimiter: ',' })).toBe('items[2]{id,note}:\n 1,"a,b"\n 2,"c,d"')
expect(encode(obj, { delimiter: '\t' })).toBe('items[2]{id,note}:\n 1\ta,b\n 2\tc,d')
})
it('does not quote commas in object values when using non-comma delimiter', () => {
expect(encode({ note: 'a,b' }, { delimiter: '|' })).toBe('note: a,b')
expect(encode({ note: 'a,b' }, { delimiter: '\t' })).toBe('note: a,b')
})
it('quotes nested array values containing the active delimiter', () => {
expect(encode({ pairs: [['a', 'b|c']] }, { delimiter: '|' })).toBe('pairs[1]:\n - [2]: a|"b|c"')
expect(encode({ pairs: [['a', 'b\tc']] }, { delimiter: '\t' })).toBe('pairs[1]:\n - [2]: a\t"b\\tc"')
})
})
describe('delimiter-independent quoting rules', () => {
it('preserves ambiguity quoting regardless of delimiter', () => {
const obj = { items: ['true', '42', '-3.14'] }
expect(encode(obj, { delimiter: '|' })).toBe('items[3]: "true"|"42"|"-3.14"')
expect(encode(obj, { delimiter: '\t' })).toBe('items[3]: "true"\t"42"\t"-3.14"')
})
it('preserves structural quoting regardless of delimiter', () => {
const obj = { items: ['[5]', '{key}', '- item'] }
expect(encode(obj, { delimiter: '|' })).toBe('items[3]: "[5]"|"{key}"|"- item"')
expect(encode(obj, { delimiter: '\t' })).toBe('items[3]: "[5]"\t"{key}"\t"- item"')
})
it('quotes keys containing the active delimiter', () => {
expect(encode({ 'a|b': 1 }, { delimiter: '|' })).toBe('"a|b": 1')
expect(encode({ 'a\tb': 1 }, { delimiter: '\t' })).toBe('"a\\tb": 1')
})
it('quotes tabular headers containing the active delimiter', () => {
const obj = { items: [{ 'a|b': 1 }, { 'a|b': 2 }] }
expect(encode(obj, { delimiter: '|' })).toBe('items[2]{"a|b"}:\n 1\n 2')
})
it('always uses commas in tabular headers regardless of delimiter', () => {
const obj = { items: [{ a: 1, b: 2 }, { a: 3, b: 4 }] }
expect(encode(obj, { delimiter: '|' })).toBe('items[2]{a,b}:\n 1|2\n 3|4')
expect(encode(obj, { delimiter: '\t' })).toBe('items[2]{a,b}:\n 1\t2\n 3\t4')
})
})
describe('formatting invariants with delimiters', () => {
it.each([
{ delimiter: '\t' as const, name: 'tab' },
{ delimiter: '|' as const, name: 'pipe' },
])('produces no trailing spaces with $name delimiter', ({ delimiter }) => {
const obj = {
user: { id: 123, name: 'Ada' },
items: ['a', 'b'],
}
const result = encode(obj, { delimiter })
const lines = result.split('\n')
for (const line of lines) {
expect(line).not.toMatch(/ $/)
}
})
it.each([
{ delimiter: '\t' as const, name: 'tab' },
{ delimiter: '|' as const, name: 'pipe' },
])('produces no trailing newline with $name delimiter', ({ delimiter }) => {
const obj = { id: 123 }
const result = encode(obj, { delimiter })
expect(result).not.toMatch(/\n$/)
})
})
})