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.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
'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 "Q", &Outline <tag> \\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="&quot; &amp; &Outline"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, '" & &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="]]>"/>'));
|
||||
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 after 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"y,z?a&b" str2="target"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&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&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']);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user