Files
AptXmlAI/ra3apt-cli/test/parser-contract.test.cjs
lanyi e0b8dedc04 Fix APT CLI roundtrip visual and script preservation
Preserve edittext channels, alpha/color transforms, inactive placement fields,
zero ratio/clipping and null ClipAction presence. Keep unlabelled flag bits,
quoted/escaped script strings, export declarations and correctly typed CONST
and pushdata values. Validate signed integers and write byte-aligned UTF-8.

Add 23 public CLI regressions and record the native 174-resource batch plus
bounded retests: four discovered pages are repaired, while the sound placeholder
and native float formatting remain explicit accuracy limits. Rebuild the 1.0.1
Windows executable and leave unrelated SDK changes untouched.
2026-10-13 02:40:37 +02:00

187 lines
9.3 KiB
JavaScript

'use strict';
// These cover issues independently reproduced by the real-tool audit.
const test = require('node:test');
const H = require('./public-cli-helper.cjs');
function rawAction(nodes, exports = '', extra = '') {
return `<aptdata>
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
<imports/><exports>${exports}</exports>
<frames><frame id="0"><action>
${nodes}
<noarg action="0"/>
</action></frame></frames>
</movieclip>
${extra}
</aptdata>`;
}
test('public build identifies a sound character payload not exported by cc3tools', () => {
const work = H.testCase('unsupported-sound');
const source = H.expanded(work, rawAction('', '', '<sound code="6" comment="not implemented yet!"/>'));
const rebuilt = H.build(work, source.xml);
H.assert.notEqual(rebuilt.result.status, 0);
H.assert.match(rebuilt.result.stderr, /cc3tools did not export the sound character payload \(code 6\)/);
});
test('public roundtrip retains an unlabelled button-action flag bit', () => {
const work = H.testCase('opaque-button-flag');
const button = '<button id="1"><buttonrecords/><buttonactions><buttonaction flags="64" keycode="0">\n' +
'<noarg action="0"/>\n</buttonaction></buttonactions></button>';
const source = H.expanded(work, rawAction('', '', button));
H.assert.equal(H.element(source.xml, 'buttonaction', 'keycode', 0).flags, '0x40');
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.equal(Number(H.element(rebuilt.raw, 'buttonaction', 'keycode', 0).flags), 64);
});
test('public action-string roundtrip retains quotes, comma, backslash and &Outline literally', () => {
const work = H.testCase('quoted-action');
const literal = 'say "Q", &Outline <tag> \\literal';
const source = H.expanded(work, rawAction('<string action="161" str="say &quot;Q&quot;, &amp;Outline &lt;tag&gt; \\literal"/>'));
H.assert.match(source.xml, /<!\[CDATA\[/);
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, literal);
});
test('public action-string roundtrip decodes XML once and preserves literal entity-looking text', () => {
const work = H.testCase('entity-literal');
const source = H.expanded(work, rawAction('<string action="161" str="&amp;quot; &amp;amp; &amp;Outline"/>'));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, '&quot; &amp; &Outline');
});
test('public action-string roundtrip preserves a literal CDATA closing delimiter', () => {
const work = H.testCase('cdata-delimiter');
const source = H.expanded(work, rawAction('<string action="161" str="]]&gt;"/>'));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, ']]>');
});
test('public action-string roundtrip retains actual LF and CR rather than replacing them with slash letters', () => {
const work = H.testCase('newlines');
const source = H.expanded(work, rawAction('<string action="161" str="before&#10;after&#13;last"/>'));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, 'before\nafter\rlast');
});
test('public geturl roundtrip does not split a comma inside an escaped quoted argument', () => {
const work = H.testCase('geturl-comma');
const source = H.expanded(work, rawAction('<geturl action="131" str1="x&quot;y,z?a&amp;b" str2="target&quot;room"/>'));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
const url = H.element(rebuilt.raw, 'geturl', 'action', 131);
H.assert.equal(url.str1, 'x"y,z?a&b');
H.assert.equal(url.str2, 'target"room');
});
test('public constant pooling accepts constructor/__proto__/toString as ordinary string values', () => {
const work = H.testCase('constant-object-keys');
const source = H.expanded(work, rawAction(`<constantpool action="136">
<constant val="0" str="constructor"/>
<constant val="1" str="__proto__"/>
<constant val="2" str="toString"/>
</constantpool>
<byte action="162" val="0"/>
<byte action="162" val="1"/>
<byte action="162" val="2"/>
<byte action="162" val="0"/>`));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.deepEqual(H.constRecords(rebuilt.output).map(record => record.value).sort(), ['__proto__', 'constructor', 'toString']);
for (const value of ['constructor', '__proto__', 'toString']) {
H.assert.equal(H.element(rebuilt.raw, 'constant', 'str', value).str, value);
}
});
test('public build writes Chinese string constants with UTF-8 byte lengths and distinct aligned pointers', () => {
const work = H.testCase('utf8-pool');
const source = H.expanded(work, rawAction(`<constantpool action="136">
<constant val="0" str="网络标题"/>
<constant val="1" str="ASCII&amp;Outline"/>
</constantpool>
<byte action="162" val="0"/>
<byte action="162" val="1"/>`));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
const records = H.constRecords(rebuilt.output);
H.assert.deepEqual(records.map(record => record.value).sort(), ['ASCII&Outline', '网络标题']);
for (const record of records) H.assert.equal(record.offset % 4, 0);
const byOffset = records.slice().sort((a, b) => a.offset - b.offset);
H.assert.ok(byOffset[1].offset >= byOffset[0].offset + Buffer.byteLength(byOffset[0].value, 'utf8') + 1);
});
test('public roundtrip writes the actual type-7 -1 sentinel as 0xffffffff without RangeError', () => {
const work = H.testCase('signed-integer-pool');
const source = H.expanded(work, rawAction(`<constantpool action="136">
<constant val="0" num="-1"/>
</constantpool>
<byte action="162" val="0"/>`));
H.assert.match(source.xml, /pushconst -1/);
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
H.assert.deepEqual(H.constRecords(rebuilt.output), [{ type: 7, bits: 0xffffffff, value: -1 }]);
H.assert.equal(H.element(rebuilt.raw, 'constant', 'num', '-1').num, '-1');
});
test('public build explicitly rejects fractional constant-pool values instead of silently truncating', () => {
const work = H.testCase('fractional-pool');
const human = `<aptdata><movieclip screensizex="1365" screensizey="768" unknown="33">
<imports/><exports/><frames><frame id="0"><action><![CDATA[
pushconst 1.25
actionEnd
]]></action></frame></frames>
</movieclip></aptdata>`;
const rebuilt = H.build(work, human);
H.assert.notEqual(rebuilt.result.status, 0);
H.assert.match(rebuilt.result.stderr, /constant|non.?integer|fraction|unsupported|numeric/i);
});
test('public build rejects an integer that would wrap into a different signed type-7 value', () => {
const work = H.testCase('integer-pool-overflow');
const human = `<aptdata><movieclip screensizex="1365" screensizey="768" unknown="33">
<imports/><exports/><frames><frame id="0"><action><![CDATA[
pushconst 2147483648
actionEnd
]]></action></frame></frames>
</movieclip></aptdata>`;
const rebuilt = H.build(work, human);
H.assert.notEqual(rebuilt.result.status, 0);
H.assert.match(rebuilt.result.stderr, /signed 32-bit integers/);
H.assert.equal(H.fs.existsSync(rebuilt.output), false);
});
test('public texture-manifest roundtrip decodes and encodes an ampersand path once', () => {
const work = H.testCase('texture-entity');
const input = H.writeRaw(work, rawAction('<noarg action="4"/>', '', '<image id="1" image="1"/>'));
H.fs.writeFileSync(input.replace(/\.apt$/, '.xml'), '<AssetDeclaration><Texture id="apt_fixture_1" File="art/A&amp;B.tga"/></AssetDeclaration>');
H.fs.writeFileSync(input.replace(/\.apt$/, '.dat'), '1->1\n');
const before = H.path.join(work.directory, 'image_edit.xml');
H.succeeded(H.cli(work, 'expand', input, before));
const human = H.fs.readFileSync(before, 'utf8');
H.assert.equal(H.element(human, 'image', 'id', 1).image, 'art/A&B.tga');
const rebuilt = H.build(work, human);
H.succeeded(rebuilt.result);
const after = H.path.join(work.directory, 'after_edit.xml');
H.succeeded(H.cli(work, 'expand', rebuilt.output, after));
H.assert.equal(H.element(H.fs.readFileSync(after, 'utf8'), 'image', 'id', 1).image, 'art/A&B.tga');
});
test('public expand/build preserves all original export names and repeated declarations in order', () => {
const work = H.testCase('export-object-keys');
const exports = '<export character="1" name="constructor"/><export character="1" name="toString"/>' +
'<export character="1" name="repeated"/><export character="1" name="repeated"/>';
const source = H.expanded(work, rawAction('<noarg action="7"/>', exports, '<empty id="1"/>'));
const rebuilt = H.build(work, source.xml);
H.succeeded(rebuilt.result);
for (const xml of [source.xml, rebuilt.raw]) {
H.assert.deepEqual(Array.from(xml.matchAll(/<export\b[^>]*>/g), match => H.attributes(match[0]).name),
['constructor', 'toString', 'repeated', 'repeated']);
}
});