feat: use language-agnostic test suite

This commit is contained in:
Johann Schopplich
2025-11-02 18:31:06 +01:00
parent 662a9d4bc5
commit 8977c8c7d6
9 changed files with 643 additions and 1993 deletions

View File

@@ -2,13 +2,13 @@
# Token-Oriented Object Notation (TOON) # Token-Oriented Object Notation (TOON)
[![CI](https://github.com/johannschopplich/toon/actions/workflows/ci.yml/badge.svg)](https://github.com/johannschopplich/toon/actions) [![CI](https://github.com/toon-format/toon/actions/workflows/ci.yml/badge.svg)](https://github.com/toon-format/toon/actions)
[![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon) [![npm version](https://img.shields.io/npm/v/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
[![SPEC v1.3](https://img.shields.io/badge/spec-v1.3-lightgrey)](./SPEC.md) [![SPEC v1.3](https://img.shields.io/badge/spec-v1.3-lightgrey)](https://github.com/toon-format/spec)
[![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon) [![npm downloads (total)](https://img.shields.io/npm/dt/@toon-format/toon.svg)](https://www.npmjs.com/package/@toon-format/toon)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./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. 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) - [Why TOON?](#why-toon)
- [Key Features](#key-features) - [Key Features](#key-features)
- [Benchmarks](#benchmarks) - [Benchmarks](#benchmarks)
- [📋 Full Specification](./SPEC.md) - [📋 Full Specification](https://github.com/toon-format/spec/blob/main/SPEC.md)
- [Installation & Quick Start](#installation--quick-start) - [Installation & Quick Start](#installation--quick-start)
- [CLI](#cli) - [CLI](#cli)
- [Format Overview](#format-overview) - [Format Overview](#format-overview)
@@ -521,7 +521,7 @@ npx @toon-format/cli data.toon --no-strict -o output.json
## Format Overview ## Format Overview
> [!NOTE] > [!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 ### Objects
@@ -980,7 +980,7 @@ Task: Return only users with role "user" as TOON. Use the same header. Set [N] t
## Other Implementations ## Other Implementations
> [!NOTE] > [!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) - **.NET:** [ToonSharp](https://github.com/0xZunia/ToonSharp)
- **Crystal:** [toon-crystal](https://github.com/mamantoha/toon-crystal) - **Crystal:** [toon-crystal](https://github.com/mamantoha/toon-crystal)

View File

@@ -8,21 +8,21 @@
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts" "fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
}, },
"devDependencies": { "devDependencies": {
"@ai-sdk/anthropic": "^2.0.37", "@ai-sdk/anthropic": "^2.0.40",
"@ai-sdk/google": "^2.0.23", "@ai-sdk/google": "^2.0.26",
"@ai-sdk/openai": "^2.0.53", "@ai-sdk/openai": "^2.0.59",
"@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider": "^2.0.0",
"@ai-sdk/xai": "^2.0.28", "@ai-sdk/xai": "^2.0.30",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"ai": "^5.0.80", "ai": "^5.0.86",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"fast-xml-parser": "^5.3.0", "fast-xml-parser": "^5.3.0",
"gpt-tokenizer": "^3.2.0", "gpt-tokenizer": "^3.2.0",
"ofetch": "^1.4.1", "ofetch": "^1.5.1",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"p-queue": "^9.0.0", "p-queue": "^9.0.0",
"unstorage": "^1.17.1", "unstorage": "^1.17.2",
"yaml": "^2.8.1" "yaml": "^2.8.1"
} }
} }

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"version": "0.7.0", "version": "0.7.0",
"private": true, "private": true,
"packageManager": "pnpm@10.19.0", "packageManager": "pnpm@10.20.0",
"scripts": { "scripts": {
"build": "pnpm -r --filter=./packages/** run build", "build": "pnpm -r --filter=./packages/** run build",
"automd": "automd", "automd": "automd",
@@ -14,14 +14,14 @@
"release": "bumpp -r" "release": "bumpp -r"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.1.0", "@antfu/eslint-config": "^6.2.0",
"@types/node": "^24.9.1", "@types/node": "^24.9.2",
"automd": "^0.4.2", "automd": "^0.4.2",
"bumpp": "^10.3.1", "bumpp": "^10.3.1",
"eslint": "^9.38.0", "eslint": "^9.39.0",
"tsdown": "^0.15.10", "tsdown": "^0.15.12",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.3" "vitest": "^4.0.6"
} }
} }

View File

@@ -2,17 +2,17 @@
"name": "@toon-format/cli", "name": "@toon-format/cli",
"type": "module", "type": "module",
"version": "0.7.0", "version": "0.7.0",
"packageManager": "pnpm@10.19.0", "packageManager": "pnpm@10.20.0",
"description": "CLI for encoding and decoding Token-Oriented Object Notation (TOON)", "description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
"author": "Johann Schopplich <hello@johannschopplich.com>", "author": "Johann Schopplich <hello@johannschopplich.com>",
"license": "MIT", "license": "MIT",
"homepage": "https://toonformat.dev", "homepage": "https://toonformat.dev",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/johannschopplich/toon.git" "url": "git+https://github.com/toon-format/toon.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/johannschopplich/toon/issues" "url": "https://github.com/toon-format/toon/issues"
}, },
"sideEffects": false, "sideEffects": false,
"exports": { "exports": {

View File

@@ -2,18 +2,26 @@
"name": "@toon-format/toon", "name": "@toon-format/toon",
"type": "module", "type": "module",
"version": "0.7.0", "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", "description": "Token-Oriented Object Notation (TOON) a token-efficient JSON alternative for LLM prompts",
"author": "Johann Schopplich <hello@johannschopplich.com>", "author": "Johann Schopplich <hello@johannschopplich.com>",
"license": "MIT", "license": "MIT",
"homepage": "https://toonformat.dev", "homepage": "https://toonformat.dev",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/johannschopplich/toon.git" "url": "git+https://github.com/toon-format/toon.git"
}, },
"bugs": { "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, "sideEffects": false,
"exports": { "exports": {
".": { ".": {
@@ -28,5 +36,8 @@
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"test": "vitest" "test": "vitest"
},
"devDependencies": {
"@toon-format/spec": "^1.3.0"
} }
} }

View File

@@ -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 { describe, expect, it } from 'vitest'
import { decode } from '../src/index' import { decode } from '../src/index'
describe('primitives', () => { const fixtureFiles = [
it('decodes safe unquoted strings', () => { primitives,
expect(decode('hello')).toBe('hello') objects,
expect(decode('Ada_99')).toBe('Ada_99') arraysPrimitive,
}) arraysTabular,
arraysNested,
delimiters,
validationErrors,
indentationErrors,
blankLines,
] as Fixtures[]
it('decodes quoted strings and unescapes control characters', () => { // Run all fixture-based tests
expect(decode('""')).toBe('') for (const fixtures of fixtureFiles) {
expect(decode('"line1\\nline2"')).toBe('line1\nline2') describe(fixtures.description, () => {
expect(decode('"tab\\there"')).toBe('tab\there') for (const test of fixtures.tests) {
expect(decode('"return\\rcarriage"')).toBe('return\rcarriage') it(test.name, () => {
expect(decode('"C:\\\\Users\\\\path"')).toBe('C:\\Users\\path') if (test.shouldError) {
expect(decode('"say \\"hello\\""')).toBe('say "hello"') expect(() => decode(test.input as string, test.options))
}) .toThrow()
}
it('decodes unicode and emoji', () => { else {
expect(decode('café')).toBe('café') const result = decode(test.input as string, test.options)
expect(decode('你好')).toBe('你好') expect(result).toEqual(test.expected)
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 },
],
}) })
}) }
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'] })
})
})
})
})

View File

@@ -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 { describe, expect, it } from 'vitest'
import { decode, encode } from '../src/index' import { decode, DEFAULT_DELIMITER, encode } from '../src/index'
describe('primitives', () => { const fixtureFiles = [
it('encodes safe strings without quotes', () => { primitives,
expect(encode('hello')).toBe('hello') objects,
expect(encode('Ada_99')).toBe('Ada_99') arraysPrimitive,
}) arraysTabular,
arraysNested,
it('quotes empty string', () => { arraysObjects,
expect(encode('')).toBe('""') delimiters,
}) normalization,
whitespace,
it('quotes strings that look like booleans or numbers', () => { options,
expect(encode('true')).toBe('"true"') ] as Fixtures[]
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')
})
// Special test for round-trip fidelity (not in JSON fixtures)
describe('round-trip fidelity', () => {
it('preserves precision for repeating decimals', () => { it('preserves precision for repeating decimals', () => {
const value = 1 / 3 const value = 1 / 3
const encodedValue = encode({ value }) const encodedValue = encode({ value })
@@ -64,714 +35,32 @@ describe('primitives', () => {
expect((decodedValue as Record<string, unknown>)?.value).toBe(value) // Round-trip fidelity expect((decodedValue as Record<string, unknown>)?.value).toBe(value) // Round-trip fidelity
expect(encodedValue).toContain('0.3333333333333333') // Default JS precision 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)', () => { // Run all fixture-based tests
it('preserves key order in objects', () => { for (const fixtures of fixtureFiles) {
const obj = { describe(fixtures.description, () => {
id: 123, for (const test of fixtures.tests) {
name: 'Ada', it(test.name, () => {
active: true, const resolvedOptions = resolveEncodeOptions(test.options)
}
expect(encode(obj)).toBe('id: 123\nname: Ada\nactive: true')
})
it('encodes null values in objects', () => { if (test.shouldError) {
const obj = { id: 123, value: null } expect(() => encode(test.input, resolvedOptions))
expect(encode(obj)).toBe('id: 123\nvalue: null') .toThrow()
}) }
else {
it('encodes empty objects as empty string', () => { const result = encode(test.input, resolvedOptions)
expect(encode({})).toBe('') expect(result).toBe(test.expected)
}) }
})
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(/ $/)
} }
}) })
}
it('produces no trailing newline at end of output', () => { function resolveEncodeOptions(options?: TestCase['options']): ResolvedEncodeOptions {
const obj = { id: 123 } return {
const result = encode(obj) indent: options?.indent ?? 2,
expect(result).not.toMatch(/\n$/) delimiter: options?.delimiter ?? DEFAULT_DELIMITER,
}) lengthMarker: options?.lengthMarker === '#' ? '#' : false,
}) }
}
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')
})
})

View 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

File diff suppressed because it is too large Load Diff