This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/app/__tests__/upload-extract.test.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
2026-08-02 13:41:44 +08:00

180 lines
6.8 KiB
TypeScript

/**
* 上传件读文本 —— **这段错了不报错,只是活动拿到一份读不懂的料**。
*
* 测法不用录制的样本文件:测里现造一个**真的 xlsx**(ZIP + 那几份 XML,按规范逐字节拼),
* 再让被测代码去读。造的那一侧与读的那一侧互不共享代码,读得回来才算真的读得懂。
*/
import { describe, expect, it } from 'vitest';
import { deflateRawSync } from 'node:zlib';
import { extractText } from '../upload-extract.ts';
/* ==================== 现造一个 xlsx ==================== */
const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
/** 按 ZIP 规范拼一个包:本地头 + 数据 + 中央目录 + EOCD */
function makeZip(entries: ReadonlyArray<{ name: string; body: Uint8Array; store?: boolean }>): Uint8Array {
const locals: Uint8Array[] = [];
const centrals: Uint8Array[] = [];
let offset = 0;
for (const e of entries) {
const name = enc(e.name);
const data = e.store === true ? e.body : new Uint8Array(deflateRawSync(e.body));
const method = e.store === true ? 0 : 8;
const local = new Uint8Array(30 + name.length + data.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(8, method, true);
lv.setUint32(18, data.length, true);
lv.setUint32(22, e.body.length, true);
lv.setUint16(26, name.length, true);
local.set(name, 30);
local.set(data, 30 + name.length);
locals.push(local);
const central = new Uint8Array(46 + name.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(10, method, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, e.body.length, true);
cv.setUint16(28, name.length, true);
cv.setUint32(42, offset, true);
central.set(name, 46);
centrals.push(central);
offset += local.length;
}
const centralSize = centrals.reduce((n, c) => n + c.length, 0);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);
const total = offset + centralSize + eocd.length;
const out = new Uint8Array(total);
let at = 0;
for (const b of [...locals, ...centrals, eocd]) {
out.set(b, at);
at += b.length;
}
return out;
}
/** 一份两列三行的 BOM:表头 + 两条料 —— 字符串走共享串表,数字直接落 `<v>` */
function bomXlsx(): Uint8Array {
const strings = ['型号', '年用量', 'LM358DR', 'NE555P'];
const sharedXml =
`<?xml version="1.0"?><sst count="4" uniqueCount="4">` +
strings.map((s) => `<si><t>${s}</t></si>`).join('') +
`</sst>`;
const sheetXml =
`<?xml version="1.0"?><worksheet><sheetData>` +
`<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>` +
`<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2"><v>12000</v></c></row>` +
`<row r="3"><c r="A3" t="s"><v>3</v></c><c r="B3"><v>5000</v></c></row>` +
`<row r="4"/>` +
`</sheetData></worksheet>`;
return makeZip([
{ name: '[Content_Types].xml', body: enc('<?xml version="1.0"?><Types/>') },
{ name: 'xl/sharedStrings.xml', body: enc(sharedXml) },
{ name: 'xl/worksheets/sheet1.xml', body: enc(sheetXml) },
]);
}
/* ==================== 断言 ==================== */
describe('xlsx —— 一份真的表读得成行列', () => {
const got = extractText('BOM.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bomXlsx());
it('读得出内容,而不是回一句「读不出」', () => {
expect(got.text).toBeDefined();
});
it('共享串表查得对 —— 字符串单元格不是数字下标', () => {
expect(got.text).toContain('LM358DR');
expect(got.text).toContain('型号');
});
it('数字单元格取原值,不查串表(查了会变成别的字)', () => {
expect(got.text).toContain('12000');
});
it('一行一行、制表符分列 —— 行列对齐了模型才拆得动', () => {
expect(got.text?.split('\n')).toEqual(['型号\t年用量', 'LM358DR\t12000', 'NE555P\t5000']);
});
it('整行皆空的不留 —— 表尾那些格式化过的空行不该占一行', () => {
expect(got.text?.split('\n')).toHaveLength(3);
});
it('读数如实报行数', () => {
expect(got.note).toContain('3 行');
});
});
describe('不压缩的条目也读得了 —— store(0) 与 deflate(8) 都是 xlsx 的出厂法', () => {
it('store 法存的表照样读得出', () => {
const zip = makeZip([
{
name: 'xl/worksheets/sheet1.xml',
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>直存</t></is></c></row></sheetData></worksheet>'),
store: true,
},
]);
expect(extractText('a.xlsx', '', zip).text).toBe('直存');
});
});
describe('读不出来就说读不出来 —— 绝不编一段内容出来', () => {
it('不是 ZIP 的 .xlsx:如实说打不开', () => {
const got = extractText('坏的.xlsx', '', enc('这不是一个 zip'));
expect(got.text).toBeUndefined();
expect(got.note).toContain('打不开');
});
it('二进制类型(pdf):说清只有文件名进得了活动', () => {
const got = extractText('规格书.pdf', 'application/pdf', new Uint8Array([1, 2, 3]));
expect(got.text).toBeUndefined();
expect(got.note).toContain('读不出文本');
});
it('空表:不是「读出 0 行」,是明说这份表是空的', () => {
const zip = makeZip([{ name: 'xl/worksheets/sheet1.xml', body: enc('<worksheet><sheetData/></worksheet>') }]);
expect(extractText('空.xlsx', '', zip).note).toContain('空的');
});
});
describe('纯文本族 —— 按 UTF-8 直接解', () => {
it('csv 原样读出(中文不乱码)', () => {
const got = extractText('bom.csv', 'text/csv', enc('型号,年用量\nLM358DR,12000'));
expect(got.text).toBe('型号,年用量\nLM358DR,12000');
});
it('超长的截断,并在读数里说明截了', () => {
const got = extractText('big.txt', 'text/plain', enc('x'.repeat(30_000)));
expect(got.text).toHaveLength(20_000);
expect(got.note).toContain('截断');
});
});
describe('XML 实体还原 —— `&amp;lt;` 之流不能原样端给模型', () => {
it('转义字符还原成本来的字', () => {
const zip = makeZip([
// 表里真实存的形态:`A&B <100>` 在 XML 里写作 `A&amp;B &lt;100&gt;`
{ name: 'xl/sharedStrings.xml', body: enc('<sst><si><t>A&amp;B &lt;100&gt;</t></si></sst>') },
{
name: 'xl/worksheets/sheet1.xml',
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>'),
},
]);
expect(extractText('e.xlsx', '', zip).text).toBe('A&B <100>');
});
});