Files
浅念 c99bbbb29e feat(adapter-wp): WXR→UniversalRow 映射器(status 值域/悬空保留/unsupported 分流)
- mapItem 纯函数:WxrItem → MappedResult 三分支(row/unsupported/error),
  DEFAULT_TYPE_ROUTES/STATUS_MAP/META_KEYS 三冻结表导出
- Plan 1 移交落实:status 值域表驱动(未知→unknown+issue);property 断言
  (isUniversalRow+refs 形状非空)测试钉死
- Task 3 审查移交:postId null→error(不产 row 不猜);-7 原样;-0 归一 '0'
- taxonomyRefs 锚点=category @nicename(=term slug,Task 8 实数据核对后冻结)
- 悬空 gallery 203/204 原样保留;wppop 解析失败→extras.phpRaw+issue(不崩不丢)
- Task 2 审查移交补测四例:d: 键拒绝/NUL 往返/s:1:"0"→Map/1MB<50ms 烟雾
- tsconfig.build.json:src 首次引 @sf/contracts 后 build 经 contracts dist
  声明解析(rootDir TS6059;typecheck/vitest/tsx 仍 src 直连,ADR-016 归口)
- 142→173 测试全绿;typecheck/lint 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 09:45:34 +08:00

260 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
phpUnserialize,
phpValueToJson,
PhpError,
PhpLimitError,
PhpParseError,
type PhpValue,
} from '../src/parse/php-unserialize.js'
// 捕获抛出的错误实例(而非仅匹配),以便断言 offset / kind / message 细节
function capture(fn: () => unknown): unknown {
try {
fn()
} catch (e) {
return e
}
throw new Error('期望抛出但未抛出')
}
describe('phpUnserialize:标量与真实夹具', () => {
it('真实夹具串(wppop_sections 形状):逐层逐值深度等值', () => {
const input = 'a:1:{i:0;a:2:{s:5:"title";s:8:"SpecsTab";s:7:"content";s:10:"<p>Tab</p>";}}'
// 外层 i:0 连续键 → 密集数组;内层 s: 键 → Map(保留插入序)
expect(phpUnserialize(input)).toStrictEqual([
new Map<string, PhpValue>([
['title', 'SpecsTab'],
['content', '<p>Tab</p>'],
]),
])
})
it('标量四类型:s → string、i → number(冻结)、b → boolean、d → number', () => {
expect(phpUnserialize('s:5:"hello";')).toBe('hello')
expect(phpUnserialize('i:42;')).toBe(42)
expect(phpUnserialize('i:-7;')).toBe(-7)
expect(phpUnserialize('b:1;')).toBe(true)
expect(phpUnserialize('b:0;')).toBe(false)
expect(phpUnserialize('d:1.5;')).toBe(1.5)
})
it('s: 长度前缀按 UTF-8 字节计:CJK(3 字节/字)与增补平面(4 字节)均准确', () => {
expect(phpUnserialize('s:6:"中文";')).toBe('中文')
expect(phpUnserialize('s:4:"😀";')).toBe('😀')
expect(phpUnserialize('s:8:"a中😀";')).toBe('a中😀')
})
})
describe('phpUnserialize:a: 形状推断(Map vs 密集数组)', () => {
it('字符串键 → Map', () => {
expect(phpUnserialize('a:1:{s:3:"foo";i:1;}')).toStrictEqual(new Map([['foo', 1]]))
})
it('连续整数键(0..n-1 依序)→ 密集数组', () => {
expect(phpUnserialize('a:3:{i:0;i:10;i:1;i:20;i:2;i:30;}')).toStrictEqual([10, 20, 30])
})
it('混合键(i: 与 s: 并存,真实 WP 数据两类都有)→ Map', () => {
expect(phpUnserialize('a:2:{i:0;b:1;s:1:"k";s:1:"v";}')).toStrictEqual(
new Map<string | number, PhpValue>([
[0, true],
['k', 'v'],
]),
)
})
it('稀疏/非 0 起始的整数键 → Map(键保留原值)', () => {
expect(phpUnserialize('a:2:{i:1;s:1:"a";i:2;s:1:"b";}')).toStrictEqual(
new Map([
[1, 'a'],
[2, 'b'],
]),
)
})
it('a:0:{} → 空数组', () => {
expect(phpUnserialize('a:0:{}')).toStrictEqual([])
})
})
describe('phpUnserialize:长度前缀与结构错误(不静默,抛 PhpParseError 带 offset)', () => {
it('长度前缀错误:声明 7 实际 8 字符 → 抛,offset 指向不符处,message 含位置', () => {
const e = capture(() => phpUnserialize('s:7:"SpecsTab";')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e).not.toBeInstanceOf(PhpLimitError)
expect(e.offset).toBe(12) // 's:7:"SpecsTa|b'——字节计与声明不符处
expect(e.message).toContain('12')
})
it('多字节字符使字节计数超出声明 → 抛', () => {
const e = capture(() => phpUnserialize('s:2:"中";')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.offset).toBe(5)
})
it('空串 → 抛,offset 0', () => {
const e = capture(() => phpUnserialize('')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.offset).toBe(0)
})
it('截断串(字符串内容未达声明字节数)→ 抛,offset 指向扫描停点', () => {
const e = capture(() => phpUnserialize('a:1:{i:0;s:5:"ab')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.offset).toBe(16)
})
it('尾随垃圾 → 抛(解析完成后必须到达串尾)', () => {
const e = capture(() => phpUnserialize('i:1;junk')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.offset).toBe(4)
})
it('非法类型前缀 x: → 抛', () => {
const e = capture(() => phpUnserialize('x:1:irrelevant;')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.offset).toBe(0)
})
it('不在支持范围的 PHP 类型(N;/O:)→ 抛(本解析器仅支持 i/b/d/s/a)', () => {
expect(capture(() => phpUnserialize('N;'))).toBeInstanceOf(PhpParseError)
expect(capture(() => phpUnserialize('O:8:"stdClass":0:{}'))).toBeInstanceOf(PhpParseError)
})
it('d:INF / d:NAN → 抛(冻结:不引入非有限数值)', () => {
expect(capture(() => phpUnserialize('d:INF;'))).toBeInstanceOf(PhpParseError)
expect(capture(() => phpUnserialize('d:NAN;'))).toBeInstanceOf(PhpParseError)
})
it('a: 声明元素数与实际不符 → 抛', () => {
expect(capture(() => phpUnserialize('a:2:{i:0;s:1:"a";}'))).toBeInstanceOf(PhpParseError)
})
it('数组键只支持 i:/s: → b: 作键抛', () => {
expect(capture(() => phpUnserialize('a:1:{b:1;i:0;}'))).toBeInstanceOf(PhpParseError)
})
})
describe('phpUnserialize:限额(默认取 DEFAULT_WXR_LIMITS:phpMaxDepth=8 / phpMaxElements=1000)', () => {
const nestStr = (depth: number): string => (depth === 0 ? 'i:0;' : `a:1:{i:0;${nestStr(depth - 1)}}`)
it('深度边界:默认限下嵌套 8 层通过,且形状逐层正确', () => {
const result = phpUnserialize(nestStr(8))
expect(JSON.stringify(result)).toBe('['.repeat(8) + '0' + ']'.repeat(8))
})
it('深度超限:第 9 层 → 抛 PhpLimitError(kind=depth,区别于 PhpParseError)', () => {
const e = capture(() => phpUnserialize(nestStr(9))) as PhpLimitError
expect(e).toBeInstanceOf(PhpLimitError)
expect(e).not.toBeInstanceOf(PhpParseError)
expect(e.kind).toBe('depth')
})
it('自定义 maxDepth:2 层过、3 层抛', () => {
expect(JSON.stringify(phpUnserialize(nestStr(2), { maxDepth: 2 }))).toBe('[[0]]')
const e = capture(() => phpUnserialize(nestStr(3), { maxDepth: 2 })) as PhpLimitError
expect(e).toBeInstanceOf(PhpLimitError)
expect(e.kind).toBe('depth')
})
it('元素数超限(预检路径):扁平 a:1001:{} → 抛 PhpLimitError(kind=elements)', () => {
const e = capture(() => phpUnserialize('a:1001:{}')) as PhpLimitError
expect(e).toBeInstanceOf(PhpLimitError)
expect(e).not.toBeInstanceOf(PhpParseError)
expect(e.kind).toBe('elements')
})
it('元素数超限(解析中计数路径):值节点计数超自定义上限抛,恰好达限放行', () => {
// 节点数 = 2 键 + 2 值 + 1 数组自身 = 5
const e = capture(() => phpUnserialize('a:2:{i:0;s:1:"a";i:1;s:1:"b";}', { maxElements: 4 })) as PhpLimitError
expect(e).toBeInstanceOf(PhpLimitError)
expect(e.kind).toBe('elements')
expect(phpUnserialize('a:2:{i:0;s:1:"a";i:1;s:1:"b";}', { maxElements: 5 })).toStrictEqual(['a', 'b'])
})
})
describe('phpValueToJson:JSON 陷阱辅助(Map 不可直接 JSON.stringify)', () => {
it('Map → 字符串键普通对象(并佐证直接 stringify Map 会静默变 {} 的陷阱)', () => {
const v = phpUnserialize('a:1:{s:1:"k";i:1;}')
expect(JSON.stringify(v)).toBe('{}') // 陷阱:Map 被 JSON.stringify 静默丢弃全部内容
expect(JSON.stringify(phpValueToJson(v))).toBe('{"k":1}')
})
it('嵌套 Map-in-array 递归往返', () => {
const v = phpUnserialize('a:2:{i:0;a:1:{s:3:"foo";s:3:"bar";}i:1;b:0;}')
expect(JSON.stringify(phpValueToJson(v))).toBe('[{"foo":"bar"},false]')
})
it('bigint 分支冻结:抛 TypeError(当前解析器不产出 bigint,拒绝静默转换)', () => {
expect(() => phpValueToJson(42n)).toThrow(TypeError)
})
})
describe('错误信息上下文摘录(offset ±20 码元窗口、控制字符转义、总长封顶)', () => {
it('message 含 offset 附近原文摘录', () => {
const e = capture(() => phpUnserialize('i:1;junk')) as PhpParseError
expect(e.message).toContain('摘录')
expect(e.message).toContain('i:1;junk')
})
it('控制字符转义为可见形式,message 不含裸控制字符', () => {
const e = capture(() => phpUnserialize('s:7:"a\nb\nc";')) as PhpParseError
expect(e.message).toContain('a\\nb\\nc')
expect(e.message).not.toContain('\n')
})
it('长输入窗口截断(… 标记)且摘录总长封顶', () => {
const e = capture(() => phpUnserialize(`i:1;${'\u0001'.repeat(60)}`)) as PhpParseError
expect(e.message).toContain('…')
const excerpt = e.message.match(/「([^」]*)」/)?.[1]
expect(excerpt).toBeDefined()
expect((excerpt ?? '').length).toBeLessThanOrEqual(123) // 120 字符封顶 + 截断/窗口省略号
})
})
// Task 2 审查移交补测(Plan 2 Task 4 落实):四例边界/形状/性能钉死
describe('Task 2 审查移交补测:数组键类型、NUL、数字串键形状、1MB 性能烟雾', () => {
it('d: 作数组键 → 拒绝(数组键只认 i:/s:,浮点键是 PHP 序列化产物中不存在的形状)', () => {
const e = capture(() => phpUnserialize('a:1:{d:1.5;i:0;}')) as PhpParseError
expect(e).toBeInstanceOf(PhpParseError)
expect(e.message).toContain('数组键')
})
it('s: 内容含 NUL(\\u0000)原样往返,不截断不报错(单指针扫描无 C 式终止符语义)', () => {
expect(phpUnserialize('s:3:"a\0b";')).toBe('a\0b')
// NUL 作键亦不炸:Map 保留该键
expect(phpUnserialize('a:1:{s:3:"a\0b";i:1;}')).toStrictEqual(new Map([['a\0b', 1]]))
})
it('数字串键 s:1:"0" → Map(密集数组判定用严格 ===,字符串键永不伪装整数键 0)', () => {
expect(phpUnserialize('a:1:{s:1:"0";i:5;}')).toStrictEqual(new Map([['0', 5]]))
expect(JSON.stringify(phpValueToJson(phpUnserialize('a:1:{s:1:"0";i:5;}')))).toBe('{"0":5}')
})
it('1MB 输入性能烟雾:<50ms(防 O(n²) 回归——单指针扫描应线性)', () => {
const parts: string[] = []
for (let i = 0; i < 400; i++) parts.push(`i:${i};s:2500:"${'x'.repeat(2500)}";`)
const input = `a:400:{${parts.join('')}}`
expect(input.length).toBeGreaterThan(1_000_000) // 确证规模 ≥1MB(纯 ASCII:码元数=字节数)
// 元素数 = 400 键 + 400 值 + 1 数组自身 = 801 ≤ 默认 phpMaxElements=1000(不经限额捷径)
const t0 = performance.now()
const value = phpUnserialize(input)
const elapsed = performance.now() - t0
expect(Array.isArray(value)).toBe(true)
expect(value).toHaveLength(400)
expect(elapsed).toBeLessThan(50)
})
})
describe('错误类型层级:PhpError 公共基类', () => {
it('PhpParseError 与 PhpLimitError 均为 PhpError 子类,且互不为对方子类', () => {
const parse = capture(() => phpUnserialize('x:1:irrelevant;')) as PhpParseError
const limit = capture(() => phpUnserialize('a:1001:{}')) as PhpLimitError
expect(parse).toBeInstanceOf(PhpError)
expect(limit).toBeInstanceOf(PhpError)
expect(parse).not.toBeInstanceOf(PhpLimitError)
expect(limit).not.toBeInstanceOf(PhpParseError)
})
})