mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
feat: use language-agnostic test suite
This commit is contained in:
12
README.md
12
README.md
@@ -2,13 +2,13 @@
|
||||
|
||||
# Token-Oriented Object Notation (TOON)
|
||||
|
||||
[](https://github.com/johannschopplich/toon/actions)
|
||||
[](https://github.com/toon-format/toon/actions)
|
||||
[](https://www.npmjs.com/package/@toon-format/toon)
|
||||
[](./SPEC.md)
|
||||
[](https://github.com/toon-format/spec)
|
||||
[](https://www.npmjs.com/package/@toon-format/toon)
|
||||
[](./LICENSE)
|
||||
|
||||
**Token-Oriented Object Notation** is a compact, human-readable format designed for passing structured data to Large Language Models with significantly reduced token usage. It's intended for LLM input, not output.
|
||||
**Token-Oriented Object Notation** is a compact, human-readable serialization format designed for passing structured data to Large Language Models with significantly reduced token usage. It's intended for LLM input, not output.
|
||||
|
||||
TOON's sweet spot is **uniform arrays of objects** – multiple fields per row, same structure across items. It borrows YAML's indentation-based structure for nested objects and CSV's tabular format for uniform data rows, then optimizes both for token efficiency in LLM contexts. For deeply nested or non-uniform data, JSON may be more efficient.
|
||||
|
||||
@@ -20,7 +20,7 @@ TOON's sweet spot is **uniform arrays of objects** – multiple fields per row,
|
||||
- [Why TOON?](#why-toon)
|
||||
- [Key Features](#key-features)
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [📋 Full Specification](./SPEC.md)
|
||||
- [📋 Full Specification](https://github.com/toon-format/spec/blob/main/SPEC.md)
|
||||
- [Installation & Quick Start](#installation--quick-start)
|
||||
- [CLI](#cli)
|
||||
- [Format Overview](#format-overview)
|
||||
@@ -521,7 +521,7 @@ npx @toon-format/cli data.toon --no-strict -o output.json
|
||||
## Format Overview
|
||||
|
||||
> [!NOTE]
|
||||
> For precise formatting rules and implementation details, see the [SPEC.md](./SPEC.md).
|
||||
> For precise formatting rules and implementation details, see the [full specification](https://github.com/toon-format/spec).
|
||||
|
||||
### Objects
|
||||
|
||||
@@ -980,7 +980,7 @@ Task: Return only users with role "user" as TOON. Use the same header. Set [N] t
|
||||
## Other Implementations
|
||||
|
||||
> [!NOTE]
|
||||
> When implementing TOON in other languages, please follow the [SPEC.md](./SPEC.md) (currently v1.3) to ensure compatibility across implementations. The [TypeScript test suite](./packages/toon/test) provides comprehensive examples of encoding and decoding behavior that can serve as a reference implementation.
|
||||
> When implementing TOON in other languages, please follow the [specification](https://github.com/toon-format/spec/blob/main/SPEC.md) (currently v1.3) to ensure compatibility across implementations. The [conformance tests](https://github.com/toon-format/spec/tree/main/tests) provide language-agnostic test fixtures that validate implementations across any language.
|
||||
|
||||
- **.NET:** [ToonSharp](https://github.com/0xZunia/ToonSharp)
|
||||
- **Crystal:** [toon-crystal](https://github.com/mamantoha/toon-crystal)
|
||||
|
||||
@@ -8,21 +8,21 @@
|
||||
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.37",
|
||||
"@ai-sdk/google": "^2.0.23",
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@ai-sdk/anthropic": "^2.0.40",
|
||||
"@ai-sdk/google": "^2.0.26",
|
||||
"@ai-sdk/openai": "^2.0.59",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/xai": "^2.0.28",
|
||||
"@ai-sdk/xai": "^2.0.30",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"ai": "^5.0.80",
|
||||
"ai": "^5.0.86",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"fast-xml-parser": "^5.3.0",
|
||||
"gpt-tokenizer": "^3.2.0",
|
||||
"ofetch": "^1.4.1",
|
||||
"ofetch": "^1.5.1",
|
||||
"p-map": "^7.0.3",
|
||||
"p-queue": "^9.0.0",
|
||||
"unstorage": "^1.17.1",
|
||||
"unstorage": "^1.17.2",
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"scripts": {
|
||||
"build": "pnpm -r --filter=./packages/** run build",
|
||||
"automd": "automd",
|
||||
@@ -14,14 +14,14 @@
|
||||
"release": "bumpp -r"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.1.0",
|
||||
"@types/node": "^24.9.1",
|
||||
"@antfu/eslint-config": "^6.2.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"automd": "^0.4.2",
|
||||
"bumpp": "^10.3.1",
|
||||
"eslint": "^9.38.0",
|
||||
"tsdown": "^0.15.10",
|
||||
"eslint": "^9.39.0",
|
||||
"tsdown": "^0.15.12",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.3"
|
||||
"vitest": "^4.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"name": "@toon-format/cli",
|
||||
"type": "module",
|
||||
"version": "0.7.0",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"description": "CLI for encoding and decoding Token-Oriented Object Notation (TOON)",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
|
||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://toonformat.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/johannschopplich/toon.git"
|
||||
"url": "git+https://github.com/toon-format/toon.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/johannschopplich/toon/issues"
|
||||
"url": "https://github.com/toon-format/toon/issues"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
"name": "@toon-format/toon",
|
||||
"type": "module",
|
||||
"version": "0.7.0",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"description": "Token-Oriented Object Notation (TOON) – a token-efficient JSON alternative for LLM prompts",
|
||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://toonformat.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/johannschopplich/toon.git"
|
||||
"url": "git+https://github.com/toon-format/toon.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/johannschopplich/toon/issues"
|
||||
"url": "https://github.com/toon-format/toon/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"toon",
|
||||
"format",
|
||||
"specification",
|
||||
"llm",
|
||||
"token-efficiency",
|
||||
"data-format"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -28,5 +36,8 @@
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@toon-format/spec": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,686 +1,42 @@
|
||||
import type { Fixtures } from './types'
|
||||
import arraysNested from '@toon-format/spec/tests/fixtures/decode/arrays-nested.json'
|
||||
import arraysPrimitive from '@toon-format/spec/tests/fixtures/decode/arrays-primitive.json'
|
||||
import arraysTabular from '@toon-format/spec/tests/fixtures/decode/arrays-tabular.json'
|
||||
import blankLines from '@toon-format/spec/tests/fixtures/decode/blank-lines.json'
|
||||
import delimiters from '@toon-format/spec/tests/fixtures/decode/delimiters.json'
|
||||
import indentationErrors from '@toon-format/spec/tests/fixtures/decode/indentation-errors.json'
|
||||
import objects from '@toon-format/spec/tests/fixtures/decode/objects.json'
|
||||
import primitives from '@toon-format/spec/tests/fixtures/decode/primitives.json'
|
||||
import validationErrors from '@toon-format/spec/tests/fixtures/decode/validation-errors.json'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decode } from '../src/index'
|
||||
|
||||
describe('primitives', () => {
|
||||
it('decodes safe unquoted strings', () => {
|
||||
expect(decode('hello')).toBe('hello')
|
||||
expect(decode('Ada_99')).toBe('Ada_99')
|
||||
})
|
||||
const fixtureFiles = [
|
||||
primitives,
|
||||
objects,
|
||||
arraysPrimitive,
|
||||
arraysTabular,
|
||||
arraysNested,
|
||||
delimiters,
|
||||
validationErrors,
|
||||
indentationErrors,
|
||||
blankLines,
|
||||
] as Fixtures[]
|
||||
|
||||
it('decodes quoted strings and unescapes control characters', () => {
|
||||
expect(decode('""')).toBe('')
|
||||
expect(decode('"line1\\nline2"')).toBe('line1\nline2')
|
||||
expect(decode('"tab\\there"')).toBe('tab\there')
|
||||
expect(decode('"return\\rcarriage"')).toBe('return\rcarriage')
|
||||
expect(decode('"C:\\\\Users\\\\path"')).toBe('C:\\Users\\path')
|
||||
expect(decode('"say \\"hello\\""')).toBe('say "hello"')
|
||||
})
|
||||
|
||||
it('decodes unicode and emoji', () => {
|
||||
expect(decode('café')).toBe('café')
|
||||
expect(decode('你好')).toBe('你好')
|
||||
expect(decode('🚀')).toBe('🚀')
|
||||
expect(decode('hello 👋 world')).toBe('hello 👋 world')
|
||||
})
|
||||
|
||||
it('decodes numbers, booleans and null', () => {
|
||||
expect(decode('42')).toBe(42)
|
||||
expect(decode('3.14')).toBe(3.14)
|
||||
expect(decode('-7')).toBe(-7)
|
||||
expect(decode('true')).toBe(true)
|
||||
expect(decode('false')).toBe(false)
|
||||
expect(decode('null')).toBe(null)
|
||||
})
|
||||
|
||||
it('treats unquoted invalid numeric formats as strings', () => {
|
||||
expect(decode('05')).toBe('05')
|
||||
expect(decode('007')).toBe('007')
|
||||
expect(decode('0123')).toBe('0123')
|
||||
expect(decode('a: 05')).toEqual({ a: '05' })
|
||||
expect(decode('nums[3]: 05,007,0123')).toEqual({ nums: ['05', '007', '0123'] })
|
||||
})
|
||||
|
||||
it('respects ambiguity quoting (quoted primitives remain strings)', () => {
|
||||
expect(decode('"true"')).toBe('true')
|
||||
expect(decode('"false"')).toBe('false')
|
||||
expect(decode('"null"')).toBe('null')
|
||||
expect(decode('"42"')).toBe('42')
|
||||
expect(decode('"-3.14"')).toBe('-3.14')
|
||||
expect(decode('"1e-6"')).toBe('1e-6')
|
||||
expect(decode('"05"')).toBe('05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects (simple)', () => {
|
||||
it('parses objects with primitive values', () => {
|
||||
const toon = 'id: 123\nname: Ada\nactive: true'
|
||||
expect(decode(toon)).toEqual({ id: 123, name: 'Ada', active: true })
|
||||
})
|
||||
|
||||
it('parses null values in objects', () => {
|
||||
const toon = 'id: 123\nvalue: null'
|
||||
expect(decode(toon)).toEqual({ id: 123, value: null })
|
||||
})
|
||||
|
||||
it('parses empty nested object header', () => {
|
||||
expect(decode('user:')).toEqual({ user: {} })
|
||||
})
|
||||
|
||||
it('parses quoted object values with special characters and escapes', () => {
|
||||
expect(decode('note: "a:b"')).toEqual({ note: 'a:b' })
|
||||
expect(decode('note: "a,b"')).toEqual({ note: 'a,b' })
|
||||
expect(decode('text: "line1\\nline2"')).toEqual({ text: 'line1\nline2' })
|
||||
expect(decode('text: "say \\"hello\\""')).toEqual({ text: 'say "hello"' })
|
||||
expect(decode('text: " padded "')).toEqual({ text: ' padded ' })
|
||||
expect(decode('text: " "')).toEqual({ text: ' ' })
|
||||
expect(decode('v: "true"')).toEqual({ v: 'true' })
|
||||
expect(decode('v: "42"')).toEqual({ v: '42' })
|
||||
expect(decode('v: "-7.5"')).toEqual({ v: '-7.5' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects (keys)', () => {
|
||||
it('parses quoted keys with special characters and escapes', () => {
|
||||
expect(decode('"order:id": 7')).toEqual({ 'order:id': 7 })
|
||||
expect(decode('"[index]": 5')).toEqual({ '[index]': 5 })
|
||||
expect(decode('"{key}": 5')).toEqual({ '{key}': 5 })
|
||||
expect(decode('"a,b": 1')).toEqual({ 'a,b': 1 })
|
||||
expect(decode('"full name": Ada')).toEqual({ 'full name': 'Ada' })
|
||||
expect(decode('"-lead": 1')).toEqual({ '-lead': 1 })
|
||||
expect(decode('" a ": 1')).toEqual({ ' a ': 1 })
|
||||
expect(decode('"123": x')).toEqual({ 123: 'x' })
|
||||
expect(decode('"": 1')).toEqual({ '': 1 })
|
||||
})
|
||||
|
||||
it('parses dotted keys as identifiers', () => {
|
||||
expect(decode('user.name: Ada')).toEqual({ 'user.name': 'Ada' })
|
||||
expect(decode('_private: 1')).toEqual({ _private: 1 })
|
||||
expect(decode('user_name: 1')).toEqual({ user_name: 1 })
|
||||
})
|
||||
|
||||
it('unescapes control characters and quotes in keys', () => {
|
||||
expect(decode('"line\\nbreak": 1')).toEqual({ 'line\nbreak': 1 })
|
||||
expect(decode('"tab\\there": 2')).toEqual({ 'tab\there': 2 })
|
||||
expect(decode('"he said \\"hi\\"": 1')).toEqual({ 'he said "hi"': 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested objects', () => {
|
||||
it('parses deeply nested objects with indentation', () => {
|
||||
const toon = 'a:\n b:\n c: deep'
|
||||
expect(decode(toon)).toEqual({ a: { b: { c: 'deep' } } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays of primitives', () => {
|
||||
it('parses string arrays inline', () => {
|
||||
const toon = 'tags[3]: reading,gaming,coding'
|
||||
expect(decode(toon)).toEqual({ tags: ['reading', 'gaming', 'coding'] })
|
||||
})
|
||||
|
||||
it('parses number arrays inline', () => {
|
||||
const toon = 'nums[3]: 1,2,3'
|
||||
expect(decode(toon)).toEqual({ nums: [1, 2, 3] })
|
||||
})
|
||||
|
||||
it('parses mixed primitive arrays inline', () => {
|
||||
const toon = 'data[4]: x,y,true,10'
|
||||
expect(decode(toon)).toEqual({ data: ['x', 'y', true, 10] })
|
||||
})
|
||||
|
||||
it('parses empty arrays', () => {
|
||||
expect(decode('items[0]:')).toEqual({ items: [] })
|
||||
})
|
||||
|
||||
it('parses quoted strings in arrays including empty and whitespace-only', () => {
|
||||
expect(decode('items[1]: ""')).toEqual({ items: [''] })
|
||||
expect(decode('items[3]: a,"",b')).toEqual({ items: ['a', '', 'b'] })
|
||||
expect(decode('items[2]: " "," "')).toEqual({ items: [' ', ' '] })
|
||||
})
|
||||
|
||||
it('parses strings with delimiters and structural tokens in arrays', () => {
|
||||
expect(decode('items[3]: a,"b,c","d:e"')).toEqual({ items: ['a', 'b,c', 'd:e'] })
|
||||
expect(decode('items[4]: x,"true","42","-3.14"')).toEqual({ items: ['x', 'true', '42', '-3.14'] })
|
||||
expect(decode('items[3]: "[5]","- item","{key}"')).toEqual({ items: ['[5]', '- item', '{key}'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays of objects (tabular and list items)', () => {
|
||||
it('parses tabular arrays of uniform objects', () => {
|
||||
const toon = 'items[2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ sku: 'A1', qty: 2, price: 9.99 },
|
||||
{ sku: 'B2', qty: 1, price: 14.5 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses nulls and quoted values in tabular rows', () => {
|
||||
const toon = 'items[2]{id,value}:\n 1,null\n 2,"test"'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ id: 1, value: null },
|
||||
{ id: 2, value: 'test' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses quoted header keys in tabular arrays', () => {
|
||||
const toon = 'items[2]{"order:id","full name"}:\n 1,Ada\n 2,Bob'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ 'order:id': 1, 'full name': 'Ada' },
|
||||
{ 'order:id': 2, 'full name': 'Bob' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses list arrays for non-uniform objects', () => {
|
||||
const toon
|
||||
= 'items[2]:\n'
|
||||
+ ' - id: 1\n'
|
||||
+ ' name: First\n'
|
||||
+ ' - id: 2\n'
|
||||
+ ' name: Second\n'
|
||||
+ ' extra: true'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ id: 1, name: 'First' },
|
||||
{ id: 2, name: 'Second', extra: true },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses objects with nested values inside list items', () => {
|
||||
const toon
|
||||
= 'items[1]:\n'
|
||||
+ ' - id: 1\n'
|
||||
+ ' nested:\n'
|
||||
+ ' x: 1'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [{ id: 1, nested: { x: 1 } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses nested tabular arrays as first field on hyphen line', () => {
|
||||
const toon
|
||||
= 'items[1]:\n'
|
||||
+ ' - users[2]{id,name}:\n'
|
||||
+ ' 1,Ada\n'
|
||||
+ ' 2,Bob\n'
|
||||
+ ' status: active'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{
|
||||
users: [
|
||||
{ id: 1, name: 'Ada' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses objects containing arrays (including empty arrays) in list format', () => {
|
||||
const toon
|
||||
= 'items[1]:\n'
|
||||
+ ' - name: test\n'
|
||||
+ ' data[0]:'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [{ name: 'test', data: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses arrays of arrays within objects', () => {
|
||||
const toon
|
||||
= 'items[1]:\n'
|
||||
+ ' - matrix[2]:\n'
|
||||
+ ' - [2]: 1,2\n'
|
||||
+ ' - [2]: 3,4\n'
|
||||
+ ' name: grid'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [{ matrix: [[1, 2], [3, 4]], name: 'grid' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays of arrays (primitives only)', () => {
|
||||
it('parses nested arrays of primitives', () => {
|
||||
const toon = 'pairs[2]:\n - [2]: a,b\n - [2]: c,d'
|
||||
expect(decode(toon)).toEqual({ pairs: [['a', 'b'], ['c', 'd']] })
|
||||
})
|
||||
|
||||
it('parses quoted strings and mixed lengths in nested arrays', () => {
|
||||
const toon = 'pairs[2]:\n - [2]: a,b\n - [3]: "c,d","e:f","true"'
|
||||
expect(decode(toon)).toEqual({ pairs: [['a', 'b'], ['c,d', 'e:f', 'true']] })
|
||||
})
|
||||
|
||||
it('parses empty inner arrays', () => {
|
||||
const toon = 'pairs[2]:\n - [0]:\n - [0]:'
|
||||
expect(decode(toon)).toEqual({ pairs: [[], []] })
|
||||
})
|
||||
|
||||
it('parses mixed-length inner arrays', () => {
|
||||
const toon = 'pairs[2]:\n - [1]: 1\n - [2]: 2,3'
|
||||
expect(decode(toon)).toEqual({ pairs: [[1], [2, 3]] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('root arrays', () => {
|
||||
it('parses root arrays of primitives (inline)', () => {
|
||||
const toon = '[5]: x,y,"true",true,10'
|
||||
expect(decode(toon)).toEqual(['x', 'y', 'true', true, 10])
|
||||
})
|
||||
|
||||
it('parses root arrays of uniform objects in tabular format', () => {
|
||||
const toon = '[2]{id}:\n 1\n 2'
|
||||
expect(decode(toon)).toEqual([{ id: 1 }, { id: 2 }])
|
||||
})
|
||||
|
||||
it('parses root arrays of non-uniform objects in list format', () => {
|
||||
const toon = '[2]:\n - id: 1\n - id: 2\n name: Ada'
|
||||
expect(decode(toon)).toEqual([{ id: 1 }, { id: 2, name: 'Ada' }])
|
||||
})
|
||||
|
||||
it('parses empty root arrays', () => {
|
||||
expect(decode('[0]:')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses root arrays of arrays', () => {
|
||||
const toon = '[2]:\n - [2]: 1,2\n - [0]:'
|
||||
expect(decode(toon)).toEqual([[1, 2], []])
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex structures', () => {
|
||||
it('parses mixed objects with arrays and nested objects', () => {
|
||||
const toon
|
||||
= 'user:\n'
|
||||
+ ' id: 123\n'
|
||||
+ ' name: Ada\n'
|
||||
+ ' tags[2]: reading,gaming\n'
|
||||
+ ' active: true\n'
|
||||
+ ' prefs[0]:'
|
||||
expect(decode(toon)).toEqual({
|
||||
user: {
|
||||
id: 123,
|
||||
name: 'Ada',
|
||||
tags: ['reading', 'gaming'],
|
||||
active: true,
|
||||
prefs: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed arrays', () => {
|
||||
it('parses arrays mixing primitives, objects and strings (list format)', () => {
|
||||
const toon
|
||||
= 'items[3]:\n'
|
||||
+ ' - 1\n'
|
||||
+ ' - a: 1\n'
|
||||
+ ' - text'
|
||||
expect(decode(toon)).toEqual({ items: [1, { a: 1 }, 'text'] })
|
||||
})
|
||||
|
||||
it('parses arrays mixing objects and arrays', () => {
|
||||
const toon
|
||||
= 'items[2]:\n'
|
||||
+ ' - a: 1\n'
|
||||
+ ' - [2]: 1,2'
|
||||
expect(decode(toon)).toEqual({ items: [{ a: 1 }, [1, 2]] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('delimiter options', () => {
|
||||
describe('basic delimiter usage', () => {
|
||||
it.each([
|
||||
{ delimiter: '\t' as const, name: 'tab', header: '[3\t]', joined: 'reading\tgaming\tcoding' },
|
||||
{ delimiter: '|' as const, name: 'pipe', header: '[3|]', joined: 'reading|gaming|coding' },
|
||||
{ delimiter: ',' as const, name: 'comma', header: '[3]', joined: 'reading,gaming,coding' },
|
||||
])('parses primitive arrays with $name delimiter', ({ header, joined }) => {
|
||||
const toon = `tags${header}: ${joined}`
|
||||
expect(decode(toon)).toEqual({ tags: ['reading', 'gaming', 'coding'] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ delimiter: '\t' as const, name: 'tab', header: '[2\t]{sku\tqty\tprice}', rows: ['A1\t2\t9.99', 'B2\t1\t14.5'] },
|
||||
{ delimiter: '|' as const, name: 'pipe', header: '[2|]{sku|qty|price}', rows: ['A1|2|9.99', 'B2|1|14.5'] },
|
||||
])('parses tabular arrays with $name delimiter', ({ header, rows }) => {
|
||||
const toon = `items${header}:\n ${rows[0]}\n ${rows[1]}`
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ sku: 'A1', qty: 2, price: 9.99 },
|
||||
{ sku: 'B2', qty: 1, price: 14.5 },
|
||||
],
|
||||
// Run all fixture-based tests
|
||||
for (const fixtures of fixtureFiles) {
|
||||
describe(fixtures.description, () => {
|
||||
for (const test of fixtures.tests) {
|
||||
it(test.name, () => {
|
||||
if (test.shouldError) {
|
||||
expect(() => decode(test.input as string, test.options))
|
||||
.toThrow()
|
||||
}
|
||||
else {
|
||||
const result = decode(test.input as string, test.options)
|
||||
expect(result).toEqual(test.expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ header: '[2\t]', inner: '[2\t]', a: 'a\tb', b: 'c\td' },
|
||||
{ header: '[2|]', inner: '[2|]', a: 'a|b', b: 'c|d' },
|
||||
])('parses nested arrays with custom delimiters', ({ header, inner, a, b }) => {
|
||||
const toon = `pairs${header}:\n - ${inner}: ${a}\n - ${inner}: ${b}`
|
||||
expect(decode(toon)).toEqual({ pairs: [['a', 'b'], ['c', 'd']] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ parent: '[1\t]', nested: '[3]', values: 'a,b,c' },
|
||||
{ parent: '[1|]', nested: '[3]', values: 'a,b,c' },
|
||||
])('nested arrays inside list items default to comma delimiter', ({ parent, nested, values }) => {
|
||||
const toon = `items${parent}:\n - tags${nested}: ${values}`
|
||||
expect(decode(toon)).toEqual({ items: [{ tags: ['a', 'b', 'c'] }] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ header: '[3\t]', joined: 'x\ty\tz' },
|
||||
{ header: '[3|]', joined: 'x|y|z' },
|
||||
])('parses root arrays of primitives with custom delimiters', ({ header, joined }) => {
|
||||
const toon = `${header}: ${joined}`
|
||||
expect(decode(toon)).toEqual(['x', 'y', 'z'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ header: '[2\t]{id}', rows: ['1', '2'] },
|
||||
{ header: '[2|]{id}', rows: ['1', '2'] },
|
||||
])('parses root arrays of objects with custom delimiters', ({ header, rows }) => {
|
||||
const toon = `${header}:\n ${rows[0]}\n ${rows[1]}`
|
||||
expect(decode(toon)).toEqual([{ id: 1 }, { id: 2 }])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('delimiter-aware quoting', () => {
|
||||
it.each([
|
||||
{ header: '[3\t]', joined: 'a\t"b\\tc"\td', expected: ['a', 'b\tc', 'd'] },
|
||||
{ header: '[3|]', joined: 'a|"b|c"|d', expected: ['a', 'b|c', 'd'] },
|
||||
])('parses values containing the active delimiter when quoted', ({ header, joined, expected }) => {
|
||||
const toon = `items${header}: ${joined}`
|
||||
expect(decode(toon)).toEqual({ items: expected })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ header: '[2\t]', joined: 'a,b\tc,d' },
|
||||
{ header: '[2|]', joined: 'a,b|c,d' },
|
||||
])('does not split on commas when using non-comma delimiter', ({ header, joined }) => {
|
||||
const toon = `items${header}: ${joined}`
|
||||
expect(decode(toon)).toEqual({ items: ['a,b', 'c,d'] })
|
||||
})
|
||||
|
||||
it('parses tabular values containing the active delimiter correctly', () => {
|
||||
const comma = 'items[2]{id,note}:\n 1,"a,b"\n 2,"c,d"'
|
||||
expect(decode(comma)).toEqual({ items: [{ id: 1, note: 'a,b' }, { id: 2, note: 'c,d' }] })
|
||||
|
||||
const tab = 'items[2\t]{id\tnote}:\n 1\ta,b\n 2\tc,d'
|
||||
expect(decode(tab)).toEqual({ items: [{ id: 1, note: 'a,b' }, { id: 2, note: 'c,d' }] })
|
||||
})
|
||||
|
||||
it('does not require quoting commas in object values when using non-comma delimiter elsewhere', () => {
|
||||
expect(decode('note: a,b')).toEqual({ note: 'a,b' })
|
||||
})
|
||||
|
||||
it('parses nested array values containing the active delimiter', () => {
|
||||
expect(decode('pairs[1|]:\n - [2|]: a|"b|c"')).toEqual({ pairs: [['a', 'b|c']] })
|
||||
expect(decode('pairs[1\t]:\n - [2\t]: a\t"b\\tc"')).toEqual({ pairs: [['a', 'b\tc']] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('delimiter-independent quoting rules', () => {
|
||||
it('preserves quoted ambiguity regardless of delimiter', () => {
|
||||
expect(decode('items[3|]: "true"|"42"|"-3.14"')).toEqual({ items: ['true', '42', '-3.14'] })
|
||||
expect(decode('items[3\t]: "true"\t"42"\t"-3.14"')).toEqual({ items: ['true', '42', '-3.14'] })
|
||||
})
|
||||
|
||||
it('parses structural-looking strings when quoted', () => {
|
||||
expect(decode('items[3|]: "[5]"|"{key}"|"- item"')).toEqual({ items: ['[5]', '{key}', '- item'] })
|
||||
expect(decode('items[3\t]: "[5]"\t"{key}"\t"- item"')).toEqual({ items: ['[5]', '{key}', '- item'] })
|
||||
})
|
||||
|
||||
it('parses tabular headers with keys containing the active delimiter', () => {
|
||||
const toon = 'items[2|]{"a|b"}:\n 1\n 2'
|
||||
expect(decode(toon)).toEqual({ items: [{ 'a|b': 1 }, { 'a|b': 2 }] })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('length marker option', () => {
|
||||
it('accepts length marker on primitive arrays', () => {
|
||||
expect(decode('tags[#3]: reading,gaming,coding')).toEqual({ tags: ['reading', 'gaming', 'coding'] })
|
||||
})
|
||||
|
||||
it('accepts length marker on empty arrays', () => {
|
||||
expect(decode('items[#0]:')).toEqual({ items: [] })
|
||||
})
|
||||
|
||||
it('accepts length marker on tabular arrays', () => {
|
||||
const toon = 'items[#2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5'
|
||||
expect(decode(toon)).toEqual({
|
||||
items: [
|
||||
{ sku: 'A1', qty: 2, price: 9.99 },
|
||||
{ sku: 'B2', qty: 1, price: 14.5 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts length marker on nested arrays', () => {
|
||||
const toon = 'pairs[#2]:\n - [#2]: a,b\n - [#2]: c,d'
|
||||
expect(decode(toon)).toEqual({ pairs: [['a', 'b'], ['c', 'd']] })
|
||||
})
|
||||
|
||||
it('works with custom delimiters and length marker', () => {
|
||||
expect(decode('tags[#3|]: reading|gaming|coding')).toEqual({ tags: ['reading', 'gaming', 'coding'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation and error handling', () => {
|
||||
describe('length and structure errors', () => {
|
||||
it('throws on array length mismatch (inline primitives)', () => {
|
||||
const toon = 'tags[2]: a,b,c'
|
||||
expect(() => decode(toon)).toThrow()
|
||||
})
|
||||
|
||||
it('throws on array length mismatch (list format)', () => {
|
||||
const toon = 'items[1]:\n - 1\n - 2'
|
||||
expect(() => decode(toon)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when tabular row value count does not match header field count', () => {
|
||||
const toon = 'items[2]{id,name}:\n 1,Ada\n 2'
|
||||
expect(() => decode(toon)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when tabular row count does not match header length', () => {
|
||||
const toon = '[1]{id}:\n 1\n 2'
|
||||
expect(() => decode(toon)).toThrow()
|
||||
})
|
||||
|
||||
it('throws on invalid escape sequences', () => {
|
||||
expect(() => decode('"a\\x"')).toThrow()
|
||||
expect(() => decode('"unterminated')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on missing colon in key-value context', () => {
|
||||
expect(() => decode('a:\n user')).toThrow()
|
||||
})
|
||||
|
||||
it('throws on delimiter mismatch', () => {
|
||||
const toon = 'items[2\t]{a\tb}:\n 1,2\n 3,4'
|
||||
expect(() => decode(toon)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('strict mode: indentation validation', () => {
|
||||
describe('non-multiple indentation errors', () => {
|
||||
it('throws when object field has non-multiple indentation', () => {
|
||||
const toon = 'a:\n b: 1' // 3 spaces with indent=2
|
||||
expect(() => decode(toon)).toThrow(/indentation/i)
|
||||
expect(() => decode(toon)).toThrow(/exact multiple/i)
|
||||
})
|
||||
|
||||
it('throws when list item has non-multiple indentation', () => {
|
||||
const toon = 'items[2]:\n - id: 1\n - id: 2' // 3 spaces
|
||||
expect(() => decode(toon)).toThrow(/indentation/i)
|
||||
})
|
||||
|
||||
it('throws with custom indent size when non-multiple', () => {
|
||||
const toon = 'a:\n b: 1' // 3 spaces with indent=4
|
||||
expect(() => decode(toon, { indent: 4 })).toThrow(/exact multiple of 4/i)
|
||||
})
|
||||
|
||||
it('accepts correct indentation with custom indent size', () => {
|
||||
const toon = 'a:\n b: 1' // 4 spaces with indent=4
|
||||
expect(decode(toon, { indent: 4 })).toEqual({ a: { b: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab character errors', () => {
|
||||
it('throws when tab character used in indentation', () => {
|
||||
const toon = 'a:\n\tb: 1'
|
||||
expect(() => decode(toon)).toThrow(/tab/i)
|
||||
})
|
||||
|
||||
it('throws when mixed tabs and spaces in indentation', () => {
|
||||
const toon = 'a:\n \tb: 1' // space + tab
|
||||
expect(() => decode(toon)).toThrow(/tab/i)
|
||||
})
|
||||
|
||||
it('throws when tab at start of line', () => {
|
||||
const toon = '\ta: 1'
|
||||
expect(() => decode(toon)).toThrow(/tab/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabs in quoted strings are allowed', () => {
|
||||
it('accepts tabs in quoted string values', () => {
|
||||
const toon = 'text: "hello\tworld"'
|
||||
expect(decode(toon)).toEqual({ text: 'hello\tworld' })
|
||||
})
|
||||
|
||||
it('accepts tabs in quoted keys', () => {
|
||||
const toon = '"key\ttab": value'
|
||||
expect(decode(toon)).toEqual({ 'key\ttab': 'value' })
|
||||
})
|
||||
|
||||
it('accepts tabs in quoted array elements', () => {
|
||||
const toon = 'items[2]: "a\tb","c\td"'
|
||||
expect(decode(toon)).toEqual({ items: ['a\tb', 'c\td'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-strict mode', () => {
|
||||
it('accepts non-multiple indentation when strict=false', () => {
|
||||
const toon = 'a:\n b: 1' // 3 spaces with indent=2
|
||||
expect(decode(toon, { strict: false })).toEqual({ a: { b: 1 } })
|
||||
})
|
||||
|
||||
it('accepts tab indentation when strict=false', () => {
|
||||
const toon = 'a:\n\tb: 1'
|
||||
// Tabs are ignored in indentation counting, so depth=0, "b: 1" at root
|
||||
expect(decode(toon, { strict: false })).toEqual({ a: {}, b: 1 })
|
||||
})
|
||||
|
||||
it('accepts deeply nested non-multiples when strict=false', () => {
|
||||
const toon = 'a:\n b:\n c: 1' // 3 and 5 spaces
|
||||
expect(decode(toon, { strict: false })).toEqual({ a: { b: { c: 1 } } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('empty lines do not trigger validation errors', () => {
|
||||
const toon = 'a: 1\n\nb: 2'
|
||||
expect(decode(toon)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('root-level content (0 indentation) is always valid', () => {
|
||||
const toon = 'a: 1\nb: 2\nc: 3'
|
||||
expect(decode(toon)).toEqual({ a: 1, b: 2, c: 3 })
|
||||
})
|
||||
|
||||
it('lines with only spaces are not validated if empty', () => {
|
||||
const toon = 'a: 1\n \nb: 2'
|
||||
expect(decode(toon)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('strict mode: blank lines in arrays', () => {
|
||||
describe('errors on blank lines inside arrays', () => {
|
||||
it('throws on blank line inside list array', () => {
|
||||
const teon = 'items[3]:\n - a\n\n - b\n - c'
|
||||
expect(() => decode(teon)).toThrow(/blank line/i)
|
||||
expect(() => decode(teon)).toThrow(/list array/i)
|
||||
})
|
||||
|
||||
it('throws on blank line inside tabular array', () => {
|
||||
const teon = 'items[2]{id}:\n 1\n\n 2'
|
||||
expect(() => decode(teon)).toThrow(/blank line/i)
|
||||
expect(() => decode(teon)).toThrow(/tabular array/i)
|
||||
})
|
||||
|
||||
it('throws on multiple blank lines inside array', () => {
|
||||
const teon = 'items[2]:\n - a\n\n\n - b'
|
||||
expect(() => decode(teon)).toThrow(/blank line/i)
|
||||
})
|
||||
|
||||
it('throws on blank line with spaces inside array', () => {
|
||||
const teon = 'items[2]:\n - a\n \n - b'
|
||||
expect(() => decode(teon)).toThrow(/blank line/i)
|
||||
})
|
||||
|
||||
it('throws on blank line in nested list array', () => {
|
||||
const teon = 'outer[2]:\n - inner[2]:\n - a\n\n - b\n - x'
|
||||
expect(() => decode(teon)).toThrow(/blank line/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('accepts blank lines outside arrays', () => {
|
||||
it('accepts blank line between root-level fields', () => {
|
||||
const teon = 'a: 1\n\nb: 2'
|
||||
expect(decode(teon)).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('accepts trailing newline at end of file', () => {
|
||||
const teon = 'a: 1\n'
|
||||
expect(decode(teon)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('accepts multiple trailing newlines', () => {
|
||||
const teon = 'a: 1\n\n\n'
|
||||
expect(decode(teon)).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('accepts blank line after array ends', () => {
|
||||
const teon = 'items[1]:\n - a\n\nb: 2'
|
||||
expect(decode(teon)).toEqual({ items: ['a'], b: 2 })
|
||||
})
|
||||
|
||||
it('accepts blank line between nested object fields', () => {
|
||||
const teon = 'a:\n b: 1\n\n c: 2'
|
||||
expect(decode(teon)).toEqual({ a: { b: 1, c: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-strict mode: ignores blank lines', () => {
|
||||
it('ignores blank lines inside list array', () => {
|
||||
const teon = 'items[3]:\n - a\n\n - b\n - c'
|
||||
expect(decode(teon, { strict: false })).toEqual({ items: ['a', 'b', 'c'] })
|
||||
})
|
||||
|
||||
it('ignores blank lines inside tabular array', () => {
|
||||
const teon = 'items[2]{id,name}:\n 1,Alice\n\n 2,Bob'
|
||||
expect(decode(teon, { strict: false })).toEqual({
|
||||
items: [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores multiple blank lines in arrays', () => {
|
||||
const teon = 'items[2]:\n - a\n\n\n - b'
|
||||
expect(decode(teon, { strict: false })).toEqual({ items: ['a', 'b'] })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,62 +1,33 @@
|
||||
import type { ResolvedEncodeOptions } from '../src/types'
|
||||
import type { Fixtures, TestCase } from './types'
|
||||
import arraysNested from '@toon-format/spec/tests/fixtures/encode/arrays-nested.json'
|
||||
import arraysObjects from '@toon-format/spec/tests/fixtures/encode/arrays-objects.json'
|
||||
import arraysPrimitive from '@toon-format/spec/tests/fixtures/encode/arrays-primitive.json'
|
||||
import arraysTabular from '@toon-format/spec/tests/fixtures/encode/arrays-tabular.json'
|
||||
import delimiters from '@toon-format/spec/tests/fixtures/encode/delimiters.json'
|
||||
import normalization from '@toon-format/spec/tests/fixtures/encode/normalization.json'
|
||||
import objects from '@toon-format/spec/tests/fixtures/encode/objects.json'
|
||||
import options from '@toon-format/spec/tests/fixtures/encode/options.json'
|
||||
import primitives from '@toon-format/spec/tests/fixtures/encode/primitives.json'
|
||||
import whitespace from '@toon-format/spec/tests/fixtures/encode/whitespace.json'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decode, encode } from '../src/index'
|
||||
import { decode, DEFAULT_DELIMITER, 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')
|
||||
})
|
||||
const fixtureFiles = [
|
||||
primitives,
|
||||
objects,
|
||||
arraysPrimitive,
|
||||
arraysTabular,
|
||||
arraysNested,
|
||||
arraysObjects,
|
||||
delimiters,
|
||||
normalization,
|
||||
whitespace,
|
||||
options,
|
||||
] as Fixtures[]
|
||||
|
||||
// Special test for round-trip fidelity (not in JSON fixtures)
|
||||
describe('round-trip fidelity', () => {
|
||||
it('preserves precision for repeating decimals', () => {
|
||||
const value = 1 / 3
|
||||
const encodedValue = encode({ value })
|
||||
@@ -64,714 +35,32 @@ describe('primitives', () => {
|
||||
expect((decodedValue as Record<string, unknown>)?.value).toBe(value) // Round-trip fidelity
|
||||
expect(encodedValue).toContain('0.3333333333333333') // Default JS precision
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
// Run all fixture-based tests
|
||||
for (const fixtures of fixtureFiles) {
|
||||
describe(fixtures.description, () => {
|
||||
for (const test of fixtures.tests) {
|
||||
it(test.name, () => {
|
||||
const resolvedOptions = resolveEncodeOptions(test.options)
|
||||
|
||||
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: ['reading', 'gaming'] }
|
||||
expect(encode(obj)).toBe('tags[2]: reading,gaming')
|
||||
})
|
||||
|
||||
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 containing delimiters in tabular rows', () => {
|
||||
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('preserves field order in list items', () => {
|
||||
const obj = { items: [{ nums: [1, 2, 3], name: 'test' }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - nums[3]: 1,2,3\n'
|
||||
+ ' name: test',
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves field order when primitive appears first', () => {
|
||||
const obj = { items: [{ name: 'test', nums: [1, 2, 3] }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - name: test\n'
|
||||
+ ' nums[3]: 1,2,3',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses list format for objects containing arrays of arrays', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ matrix: [[1, 2], [3, 4]], name: 'grid' },
|
||||
],
|
||||
}
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - matrix[2]:\n'
|
||||
+ ' - [2]: 1,2\n'
|
||||
+ ' - [2]: 3,4\n'
|
||||
+ ' name: grid',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses tabular format for nested uniform object arrays', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ users: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Bob' }], status: 'active' },
|
||||
],
|
||||
}
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - users[2]{id,name}:\n'
|
||||
+ ' 1,Ada\n'
|
||||
+ ' 2,Bob\n'
|
||||
+ ' status: active',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses list format for nested object arrays with mismatched keys', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ users: [{ id: 1, name: 'Ada' }, { id: 2 }], status: 'active' },
|
||||
],
|
||||
}
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - users[2]:\n'
|
||||
+ ' - id: 1\n'
|
||||
+ ' name: Ada\n'
|
||||
+ ' - id: 2\n'
|
||||
+ ' status: active',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses list format for objects with multiple array fields', () => {
|
||||
const obj = { items: [{ nums: [1, 2], tags: ['a', 'b'], name: 'test' }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - nums[2]: 1,2\n'
|
||||
+ ' tags[2]: a,b\n'
|
||||
+ ' name: test',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses list format for objects with only array fields', () => {
|
||||
const obj = { items: [{ nums: [1, 2, 3], tags: ['a', 'b'] }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - nums[3]: 1,2,3\n'
|
||||
+ ' tags[2]: a,b',
|
||||
)
|
||||
})
|
||||
|
||||
it('handles objects with empty arrays in list format', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ name: 'test', data: [] },
|
||||
],
|
||||
}
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - name: test\n'
|
||||
+ ' data[0]:',
|
||||
)
|
||||
})
|
||||
|
||||
it('places first field of nested tabular arrays on hyphen line', () => {
|
||||
const obj = { items: [{ users: [{ id: 1 }, { id: 2 }], note: 'x' }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - users[2]{id}:\n'
|
||||
+ ' 1\n'
|
||||
+ ' 2\n'
|
||||
+ ' note: x',
|
||||
)
|
||||
})
|
||||
|
||||
it('places empty arrays on hyphen line when first', () => {
|
||||
const obj = { items: [{ data: [], name: 'x' }] }
|
||||
expect(encode(obj)).toBe(
|
||||
'items[1]:\n'
|
||||
+ ' - data[0]:\n'
|
||||
+ ' name: x',
|
||||
)
|
||||
})
|
||||
|
||||
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 strings containing delimiters in nested arrays', () => {
|
||||
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 arrays of arrays at root level', () => {
|
||||
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: ['reading', 'gaming'],
|
||||
active: true,
|
||||
prefs: [],
|
||||
},
|
||||
}
|
||||
expect(encode(obj)).toBe(
|
||||
'user:\n'
|
||||
+ ' id: 123\n'
|
||||
+ ' name: Ada\n'
|
||||
+ ' tags[2]: reading,gaming\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(/ $/)
|
||||
if (test.shouldError) {
|
||||
expect(() => encode(test.input, resolvedOptions))
|
||||
.toThrow()
|
||||
}
|
||||
else {
|
||||
const result = encode(test.input, resolvedOptions)
|
||||
expect(result).toBe(test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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: 'reading\tgaming\tcoding' },
|
||||
{ delimiter: '|' as const, name: 'pipe', expected: 'reading|gaming|coding' },
|
||||
{ delimiter: ',' as const, name: 'comma', expected: 'reading,gaming,coding' },
|
||||
])('encodes primitive arrays with $name', ({ delimiter, expected }) => {
|
||||
const obj = { tags: ['reading', 'gaming', 'coding'] }
|
||||
expect(encode(obj, { delimiter })).toBe(`tags[3${delimiter !== ',' ? delimiter : ''}]: ${expected}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ delimiter: '\t' as const, name: 'tab', expected: 'items[2\t]{sku\tqty\tprice}:\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, 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\t]:\n - [2\t]: a\tb\n - [2\t]: c\td' },
|
||||
{ delimiter: '|' as const, name: 'pipe', expected: 'pairs[2|]:\n - [2|]: a|b\n - [2|]: c|d' },
|
||||
])('encodes nested arrays with $name', ({ 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 }) => {
|
||||
const arr = ['x', 'y', 'z']
|
||||
expect(encode(arr, { delimiter })).toBe(`[3${delimiter}]: x${delimiter}y${delimiter}z`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ delimiter: '\t' as const, name: 'tab', expected: '[2\t]{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, 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 $name', ({ delimiter, input, expected }) => {
|
||||
expect(encode({ items: input }, { delimiter })).toBe(`items[${input.length}${delimiter}]: ${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 with $name', ({ delimiter, input, expected }) => {
|
||||
expect(encode({ items: input }, { delimiter })).toBe(`items[${input.length}${delimiter}]: ${expected}`)
|
||||
})
|
||||
|
||||
it('quotes tabular values containing the delimiter', () => {
|
||||
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\t]{id\tnote}:\n 1\ta,b\n 2\tc,d')
|
||||
})
|
||||
|
||||
it('does not quote commas in object values with 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 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\t]:\n - [2\t]: 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\t]: "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\t]: "[5]"\t"{key}"\t"- item"')
|
||||
})
|
||||
|
||||
it('quotes keys containing the 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 delimiter', () => {
|
||||
const obj = { items: [{ 'a|b': 1 }, { 'a|b': 2 }] }
|
||||
expect(encode(obj, { delimiter: '|' })).toBe('items[2|]{"a|b"}:\n 1\n 2')
|
||||
})
|
||||
|
||||
it('header uses the active 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\t]{a\tb}:\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 }) => {
|
||||
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 }) => {
|
||||
const obj = { id: 123 }
|
||||
const result = encode(obj, { delimiter })
|
||||
expect(result).not.toMatch(/\n$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('length marker option', () => {
|
||||
it('adds length marker to primitive arrays', () => {
|
||||
const obj = { tags: ['reading', 'gaming', 'coding'] }
|
||||
expect(encode(obj, { lengthMarker: '#' })).toBe('tags[#3]: reading,gaming,coding')
|
||||
})
|
||||
|
||||
it('handles empty arrays', () => {
|
||||
expect(encode({ items: [] }, { lengthMarker: '#' })).toBe('items[#0]:')
|
||||
})
|
||||
|
||||
it('adds length marker to tabular arrays', () => {
|
||||
const obj = {
|
||||
items: [
|
||||
{ sku: 'A1', qty: 2, price: 9.99 },
|
||||
{ sku: 'B2', qty: 1, price: 14.5 },
|
||||
],
|
||||
}
|
||||
expect(encode(obj, { lengthMarker: '#' })).toBe('items[#2]{sku,qty,price}:\n A1,2,9.99\n B2,1,14.5')
|
||||
})
|
||||
|
||||
it('adds length marker to nested arrays', () => {
|
||||
const obj = { pairs: [['a', 'b'], ['c', 'd']] }
|
||||
expect(encode(obj, { lengthMarker: '#' })).toBe('pairs[#2]:\n - [#2]: a,b\n - [#2]: c,d')
|
||||
})
|
||||
|
||||
it('works with delimiter option', () => {
|
||||
const obj = { tags: ['reading', 'gaming', 'coding'] }
|
||||
expect(encode(obj, { lengthMarker: '#', delimiter: '|' })).toBe('tags[#3|]: reading|gaming|coding')
|
||||
})
|
||||
|
||||
it('default is false (no length marker)', () => {
|
||||
const obj = { tags: ['reading', 'gaming', 'coding'] }
|
||||
expect(encode(obj)).toBe('tags[3]: reading,gaming,coding')
|
||||
})
|
||||
})
|
||||
function resolveEncodeOptions(options?: TestCase['options']): ResolvedEncodeOptions {
|
||||
return {
|
||||
indent: options?.indent ?? 2,
|
||||
delimiter: options?.delimiter ?? DEFAULT_DELIMITER,
|
||||
lengthMarker: options?.lengthMarker === '#' ? '#' : false,
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/toon/test/types.ts
Normal file
29
packages/toon/test/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Type definitions for TOON test fixtures
|
||||
*
|
||||
* @remarks
|
||||
* Matches the JSON schema at https://github.com/toon-format/spec/blob/main/tests/fixtures.schema.json.
|
||||
*/
|
||||
|
||||
export interface TestCase {
|
||||
name: string
|
||||
input: unknown
|
||||
expected: unknown
|
||||
shouldError?: boolean
|
||||
options?: {
|
||||
delimiter?: ',' | '\t' | '|'
|
||||
indent?: number
|
||||
lengthMarker?: '#' | ''
|
||||
strict?: boolean
|
||||
}
|
||||
specSection?: string
|
||||
note?: string
|
||||
minSpecVersion?: string
|
||||
}
|
||||
|
||||
export interface Fixtures {
|
||||
version: string
|
||||
category: 'encode' | 'decode'
|
||||
description: string
|
||||
tests: TestCase[]
|
||||
}
|
||||
1017
pnpm-lock.yaml
generated
1017
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user