Files
MetonaSqlark/tests/v045-hardening.test.ts
thzxx 0dba1abf2a test(P0): v0.8.0 验证基座与工程门禁根治
工作流 C-1 / C-3 前半 + 测试代码类型检查。

【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts
- TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入
  (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃)
- 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现
  导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness)
- 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码
  与 entry.content.subarray 恒等分支)
- FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close()
  (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相)
- 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组)

【覆盖率口径】jest.config.cjs
- 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体
  排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由
- 新增 coverageThreshold 门禁(此前完全不存在)
- 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43%
- 修正 testMatch 使 tests/helpers 下的测试可被发现

【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests
- 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏)
- 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode
  以断言收窄替代 as any
- 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码)
- parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例,
  当前红灯,对应总账第 11 项,将在工作流 A 修复)

【版本契约】新增 tests/version-contract.test.ts
- 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量

【CI 门禁】.gitea/workflows/ci.yml
- lint 去掉 continue-on-error(此前永远不让 CI 变红)
- 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验
- 版本 0.7.4 升至 0.8.0
2026-09-14 21:03:06 +08:00

232 lines
10 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.
/**
* v0.4.4 回归测试 — SSTable 编码缺陷修复
* 覆盖:
* - P0: 大段中文内容(300KB)写入 aria → flush → close → reopen 数据完整(缓冲区低估越界)
* - P0: >64KB valueu16 长度截断)
* - P0: 大 value 超过块大小(4096)→ 单条独立成块读取正确
* - P1: v1 旧格式文件兼容读取(旧库数据不丢)
* - P1: 中文 key / 混合条目
*/
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import type { SSTableMeta } from '../src/engine/aria/types';
import { resetOPFSMock } from './helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
let idbCounter = 0;
function uniqueDB(): string {
return `r45-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
/** 300KB 中文内容(模拟真实笔记/文档数据) */
function makeBigChineseContent(size = 300 * 1024): string {
const chunk = '这是一段用于测试的中文内容。数据持久化必须正确处理 UTF-8 多字节编码!';
return chunk.repeat(Math.ceil(size / chunk.length)).slice(0, size);
}
// ===================================================================
// P0: 大内容编码(builder 字节估算)
// ===================================================================
describe('P0 — SSTable 编码:UTF-8 字节估算', () => {
it('构建含 300KB 中文内容的 SSTable 不越界,可完整读回', () => {
const builder = new SSTableBuilder(4096);
const content = makeBigChineseContent();
builder.add('note:1', { id: '1', title: '长文笔记', body: content });
builder.add('note:2', { id: '2', body: '小内容' });
const { sstableData } = builder.build();
// 修复前:缓冲区低估 → build 抛 RangeError
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const note1 = reader.get('note:1');
expect(note1).not.toBeNull();
expect((note1 as any).body).toBe(content);
expect((note1 as any).body.length).toBe(content.length);
});
it('构建含 >64KB value 的 SSTableu32 长度字段,修复前 u16 截断)', () => {
const builder = new SSTableBuilder(4096);
const content = makeBigChineseContent(70 * 1024); // 70KB > 64KB
builder.add('doc:big', { id: 'big', data: content });
builder.add('doc:small', { id: 'small', data: 'x' });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const big = reader.get('doc:big');
expect((big as any).data).toBe(content);
expect((reader.get('doc:small') as any).data).toBe('x');
});
it('大 value>4096 块大小)单条独立成块,scanAll/get 均正确', () => {
const builder = new SSTableBuilder(64); // 极小块阈值
const big = makeBigChineseContent(8 * 1024);
for (let i = 0; i < 5; i++) {
builder.add(`k-${i}`, { v: i, body: i === 2 ? big : 'small' });
}
const { sstableData, indexEntries } = builder.build();
// 大条目独立成块(块数 > 1)
expect(indexEntries.length).toBeGreaterThan(1);
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const collected: string[] = [];
reader.scanAll((k) => collected.push(k));
expect(collected).toEqual(['k-0', 'k-1', 'k-2', 'k-3', 'k-4']);
expect((reader.get('k-2') as any).body).toBe(big);
});
it('中文 key(多字节)与混合条目', () => {
const builder = new SSTableBuilder(4096);
builder.add('表:笔记一', { 标题: '中文标题', 内容: '内容' });
builder.add('表:笔记二', { 标题: '第二篇' });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect((reader.get('表:笔记一') as any)['标题']).toBe('中文标题');
expect((reader.get('表:笔记二') as any)['标题']).toBe('第二篇');
});
it('v2 魔数写入正确(SSTC', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const magic = new DataView(sstableData.buffer, sstableData.byteOffset, sstableData.byteLength)
.getUint32(sstableData.byteLength - 8, false);
expect(magic).toBe(0x53535443); // "SSTC"
});
});
// ===================================================================
// P1: v1 旧格式兼容
// ===================================================================
describe('P1 — v1 旧格式兼容', () => {
/** 手工构造 v1 格式(keyLen/valueLen u16 + magic SSTB */
function buildV1SSTable(entries: [string, Record<string, unknown>][]): Uint8Array {
const enc = new TextEncoder();
const encoded = entries.map(([k, v]) => ({ key: k, keyBytes: enc.encode(k), valueBytes: enc.encode(JSON.stringify(v)) }));
// 块(全部放一个块)
const entryCount = entries.length;
let blockSize = 4;
for (const e of encoded) blockSize += 2 + e.keyBytes.length + 2 + e.valueBytes.length;
const indexSize = 4 + 2 + encoded[encoded.length - 1].keyBytes.length + 8;
const _bloomSize = 0;
const total = blockSize + indexSize + 32;
const buf = new ArrayBuffer(total);
const view = new DataView(buf);
let offset = 0;
view.setUint32(offset, entryCount, false); offset += 4;
for (const e of encoded) {
view.setUint16(offset, e.keyBytes.length, false); offset += 2;
new Uint8Array(buf).set(e.keyBytes, offset); offset += e.keyBytes.length;
view.setUint16(offset, e.valueBytes.length, false); offset += 2;
new Uint8Array(buf).set(e.valueBytes, offset); offset += e.valueBytes.length;
}
const indexOffset = offset;
view.setUint32(offset, 1, false); offset += 4;
const last = encoded[encoded.length - 1];
view.setUint16(offset, last.keyBytes.length, false); offset += 2;
new Uint8Array(buf).set(last.keyBytes, offset); offset += last.keyBytes.length;
view.setUint32(offset, 0, false); offset += 4;
view.setUint32(offset, blockSize, false); offset += 4;
const footer = offset;
view.setUint32(footer, indexOffset, false);
view.setUint32(footer + 4, indexSize, false);
view.setUint32(footer + 8, 0, false); // bloom_offset
view.setUint32(footer + 12, 0, false); // bloom_size
view.setUint32(footer + 16, 0, false); // hash_count
view.setUint32(footer + 20, entryCount, false);
view.setUint32(footer + 24, 0x53535442, false); // SSTB
view.setUint32(footer + 28, 0, false);
return new Uint8Array(buf);
}
it('v1 格式文件可读(旧库数据不丢)', () => {
const v1 = buildV1SSTable([
['u:1', { name: 'Alice', age: 30 }],
['u:2', { name: 'Bob', age: 25 }],
]);
const reader = new SSTableReader(v1, makeMeta(v1));
expect((reader.get('u:1') as any).name).toBe('Alice');
expect((reader.get('u:2') as any).name).toBe('Bob');
const keys: string[] = [];
reader.scanAll((k) => keys.push(k));
expect(keys).toEqual(['u:1', 'u:2']);
});
});
// ===================================================================
// P0: 端到端 — 大内容跨重启(爸爸 Electron 冒烟场景)
// ===================================================================
describe('P0 — 大内容端到端(aria 模式)', () => {
it('300KB 中文写入 → close → reopen 数据完整(修复前打开必崩)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('notes', {
id: { type: 'string', primaryKey: true },
title: { type: 'string' },
body: { type: 'string' },
}));
const content = makeBigChineseContent();
await engine.insert('notes', [
{ id: 'n1', title: '长文', body: content },
{ id: 'n2', title: '短文', body: 'hello' },
]);
// 强制落盘(修复前:flush 编码越界崩溃)
await (engine as any).lsm.flush();
await engine.close();
// 修复前:打开解析半写文件 → ARIA_OPEN_ERROR
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
const rows = await engine2.find('notes', { table: 'notes' });
expect(rows).toHaveLength(2);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId['n1'].body).toBe(content);
expect(byId['n1'].body.length).toBe(content.length);
expect(byId['n2'].body).toBe('hello');
await engine2.close();
});
it('多行 >64KB 内容批量写入重开完整(含索引列)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
memtableSizeThreshold: 32 * 1024,
checkpointInterval: 100000,
});
await engine.open(dbName, 1);
await engine.createTable(createSchema('docs', {
id: { type: 'string', primaryKey: true },
tag: { type: 'string', index: true },
body: { type: 'string' },
}));
const contents: string[] = [];
for (let i = 0; i < 10; i++) {
const c = makeBigChineseContent(70 * 1024 + i * 100);
contents.push(c);
await engine.insert('docs', [{ id: `d${i}`, tag: i % 2 === 0 ? 'even' : 'odd', body: c }]);
}
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
await engine2.open(dbName, 1);
expect(await engine2.count('docs')).toBe(10);
const evens = await engine2.find('docs', { table: 'docs', where: { tag: 'even' } });
expect(evens).toHaveLength(5);
for (let i = 0; i < 10; i++) {
const rows = await engine2.find('docs', { table: 'docs', where: { id: `d${i}` } });
expect(rows[0].body).toBe(contents[i]);
}
await engine2.close();
});
});