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,16 @@
|
||||
# Public CLI regression tests
|
||||
|
||||
Run `npm test` from `ra3apt-cli/` after installing the normal dependencies. All 23 tests invoke the real `bin/ra3apt.js` dispatcher and public expand/build paths in child Node processes. They exercise production XML transformation, assembly parsing, constant pooling and CONST writing. Private conversion functions are not exposed or mocked.
|
||||
|
||||
`fake-cc3tools.cjs` intercepts only the two external converter calls. It transports raw XML in an unmistakable `RA3_CLI_TEST_RAW_XML_ENVELOPE` file and refuses real APT binaries. It performs no flags, color, string, numeric, ID or XML transformation and does not generate CONST files. Its outputs are simulated envelopes, not game resources. Real cc3tools compatibility and game behavior require separate validation.
|
||||
|
||||
- `visual-state.test.cjs`: six tests covering an active null clip-action pointer without creating an array, actual height/RGB/translation edits, unrelated fields, transparent/dim/additive transforms, Move-only placement, active zero ratio, clipdepth and nonidentity matrices.
|
||||
- `supported-bytecode.test.cjs`: three tests covering fractional pushfloat, numeric-looking strings versus integers, and correctly typed pushdata/global CONST references.
|
||||
- `parser-contract.test.cjs`: fourteen tests covering converter-unimplemented character rejection and unlabelled flag bits, quotes, backslashes, commas, ampersands, entity-looking text, LF/CR, inherited-object-key names, UTF-8 byte lengths/pointers, signed type-7 constants, fractional/overflow rejection, export declarations, literal CDATA delimiters and manifest ampersands.
|
||||
- `public-cli-helper.cjs` and `visual-state.raw.xml`: process isolation and independent raw XML/CONST assertions.
|
||||
|
||||
By default artifacts go into a unique child of `ra3apt-cli/.work/tests/`, which is Git-ignored. Set `RA3_CLI_TEST_ARTIFACT_ROOT` to direct all artifacts to a task-specific directory. TEMP/TMP/TMPDIR are bound inside that directory. Set `RA3_APT_CLI_ROOT` to test a different source checkout and `NODE_PATH` to reuse existing dependencies without copying them. Do not commit generated files or dependency copies.
|
||||
|
||||
All 23 tests passed on 2026-10-13. Assertions check edited and unrelated values, actual string/integer types, UTF-8 lengths, pointer alignment and relevant bit patterns; they permit legitimate constant-pool reordering. Fractional pool operands such as `pushconst 1.25` are rejected because existing type 7 is signed32; fractional values use `pushfloat`. UTF-8 tests prove stored bytes, not game font support.
|
||||
|
||||
The real converter emits many float32 fields to six decimal places. Equal expanded XML does not prove bit-exact preservation of the original float32 values. See the CLI README and roundtrip report for this limit and the separate native resource regression.
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
// Test-only transport boundary. This file does not emulate any CLI transformation.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
const marker = 'RA3_CLI_TEST_RAW_XML_ENVELOPE\n';
|
||||
|
||||
cp.execFileSync = function fakeConverter(file, args) {
|
||||
const command = path.basename(file).replace(/\.exe$/i, '').toLowerCase();
|
||||
if (!['apt2xml', 'xml2apt'].includes(command) || args.length !== 1) {
|
||||
throw new Error('The test transport permits only apt2xml/xml2apt with one base path');
|
||||
}
|
||||
const base = args[0];
|
||||
if (command === 'apt2xml') {
|
||||
const envelope = fs.readFileSync(base + '.apt', 'utf8');
|
||||
if (!envelope.startsWith(marker)) {
|
||||
throw new Error('The simulated converter refuses real APT binaries');
|
||||
}
|
||||
fs.writeFileSync(base + '.xml', envelope.slice(marker.length));
|
||||
} else {
|
||||
// Keep the .const that production build() wrote; do not supply a fake one.
|
||||
fs.writeFileSync(base + '.apt', marker + fs.readFileSync(base + '.xml', 'utf8'));
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const marker = 'RA3_CLI_TEST_RAW_XML_ENVELOPE\n';
|
||||
const cliRoot = path.resolve(process.env.RA3_APT_CLI_ROOT || path.join(__dirname, '..'));
|
||||
const runRoot = path.resolve(process.env.RA3_CLI_TEST_ARTIFACT_ROOT || path.join(cliRoot, '.work', 'tests'));
|
||||
|
||||
function decodeAttribute(value) {
|
||||
return value.replace(/&(?:amp|quot|apos|lt|gt);|&#(?:x[0-9a-f]+|[0-9]+);/gi, item => {
|
||||
const named = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>' };
|
||||
if (named[item] !== undefined) return named[item];
|
||||
return String.fromCodePoint(item[2].toLowerCase() === 'x' ?
|
||||
parseInt(item.slice(3, -1), 16) : parseInt(item.slice(2, -1), 10));
|
||||
});
|
||||
}
|
||||
|
||||
function attributes(tag) {
|
||||
const result = {};
|
||||
for (const match of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) {
|
||||
result[match[1]] = decodeAttribute(match[2]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function element(xml, tag, attribute, value) {
|
||||
const tags = Array.from(xml.matchAll(new RegExp(String.raw`<${tag}\b(?:[^>"']|"[^"]*"|'[^']*')*>`, 'g')),
|
||||
match => attributes(match[0]));
|
||||
const found = tags.filter(attrs => attrs[attribute] === String(value));
|
||||
assert.equal(found.length, 1, `one ${tag} ${attribute}=${value} must exist`);
|
||||
return found[0];
|
||||
}
|
||||
|
||||
function frame(xml, id) {
|
||||
const match = Array.from(xml.matchAll(/<frame\b([^>]*)>([\s\S]*?)<\/frame>/g))
|
||||
.find(item => attributes(item[1]).id === String(id));
|
||||
assert.ok(match, `frame ${id} must exist`);
|
||||
return match[2];
|
||||
}
|
||||
|
||||
function testCase(name) {
|
||||
fs.mkdirSync(runRoot, { recursive: true });
|
||||
const directory = fs.mkdtempSync(path.join(runRoot, name + '-'));
|
||||
const temp = path.join(directory, 'temp');
|
||||
const tools = path.join(directory, 'fake-tools');
|
||||
fs.mkdirSync(temp);
|
||||
fs.mkdirSync(tools);
|
||||
return { directory, temp, tools };
|
||||
}
|
||||
|
||||
function cli(test, command, input, output) {
|
||||
const result = spawnSync(process.execPath, [
|
||||
'--require', path.join(__dirname, 'fake-cc3tools.cjs'),
|
||||
path.join(cliRoot, 'bin', 'ra3apt.js'), command, input,
|
||||
'-o', output, '--tools-path', test.tools
|
||||
], {
|
||||
encoding: 'utf8', cwd: test.directory,
|
||||
env: { ...process.env, TEMP: test.temp, TMP: test.temp, TMPDIR: test.temp }
|
||||
});
|
||||
fs.writeFileSync(path.join(test.directory, command + '.log'), result.stdout + result.stderr);
|
||||
return result;
|
||||
}
|
||||
|
||||
function succeeded(result) {
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
function writeRaw(test, raw) {
|
||||
const input = path.join(test.directory, 'fixture.apt');
|
||||
fs.writeFileSync(input, marker + raw);
|
||||
return input;
|
||||
}
|
||||
|
||||
function rawOutput(output) {
|
||||
const envelope = fs.readFileSync(output, 'utf8');
|
||||
assert.ok(envelope.startsWith(marker));
|
||||
return envelope.slice(marker.length);
|
||||
}
|
||||
|
||||
function expanded(test, raw) {
|
||||
const output = path.join(test.directory, 'fixture_edit.xml');
|
||||
succeeded(cli(test, 'expand', writeRaw(test, raw), output));
|
||||
return { file: output, xml: fs.readFileSync(output, 'utf8') };
|
||||
}
|
||||
|
||||
function build(test, human, stem = 'rebuilt') {
|
||||
const input = path.join(test.directory, stem + '_edit.xml');
|
||||
const output = path.join(test.directory, stem + '.apt');
|
||||
fs.writeFileSync(input, human);
|
||||
const result = cli(test, 'build', input, output);
|
||||
return { result, output, raw: result.status === 0 ? rawOutput(output) : null };
|
||||
}
|
||||
|
||||
function constRecords(output) {
|
||||
const table = fs.readFileSync(output.replace(/\.apt$/, '.const'));
|
||||
const count = table.readUInt32LE(0x18);
|
||||
const records = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = 0x20 + i * 8;
|
||||
const type = table.readUInt32LE(offset);
|
||||
const bits = table.readUInt32LE(offset + 4);
|
||||
assert.ok(type === 1 || type === 7, 'these fixtures use existing string/integer CONST encodings');
|
||||
if (type === 1) {
|
||||
const end = table.indexOf(0, bits);
|
||||
assert.ok(end >= bits);
|
||||
records.push({ type, value: table.subarray(bits, end).toString('utf8'), offset: bits });
|
||||
} else {
|
||||
records.push({ type, bits, value: table.readInt32LE(offset + 4) });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
module.exports = { assert, fs, path, attributes, element, frame, testCase, cli,
|
||||
succeeded, writeRaw, rawOutput, expanded, build, constRecords, cliRoot };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const H = require('./public-cli-helper.cjs');
|
||||
|
||||
function rawAction(nodes) {
|
||||
return `<aptdata>
|
||||
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/>
|
||||
<frames><frame id="0"><action>
|
||||
${nodes}
|
||||
<noarg action="0"/>
|
||||
</action></frame></frames>
|
||||
</movieclip>
|
||||
</aptdata>`;
|
||||
}
|
||||
|
||||
test('public expand/build preserves an edited supported pushfloat without integer truncation', () => {
|
||||
const work = H.testCase('pushfloat');
|
||||
const source = H.expanded(work, rawAction('<float action="180" val="85.25"/>'));
|
||||
H.assert.match(source.xml, /pushfloat 85\.25/);
|
||||
const edited = source.xml.replace('pushfloat 85.25', 'pushfloat 24.5');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(rebuilt.raw, 'float', 'action', 180).val), 24.5);
|
||||
});
|
||||
|
||||
test('public expand/build distinguishes a numeric-looking quoted string from an integer pool value', () => {
|
||||
const work = H.testCase('quoted-numeric');
|
||||
const source = H.expanded(work, rawAction(`<constantpool action="136">
|
||||
<constant val="0" str="007"/>
|
||||
<constant val="1" num="7"/>
|
||||
</constantpool>
|
||||
<byte action="162" val="0"/>
|
||||
<byte action="162" val="1"/>`));
|
||||
H.assert.match(source.xml, /pushconst "007"/);
|
||||
H.assert.match(source.xml, /pushconst 7(?:\s|<)/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'str', '007').str, '007');
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'num', '7').num, '7');
|
||||
// This .const was generated by production build(), not by the fake converter.
|
||||
const table = H.fs.readFileSync(rebuilt.output.replace(/\.apt$/, '.const'));
|
||||
H.assert.equal(table.readUInt32LE(0x18), 2);
|
||||
const values = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offset = 0x20 + i * 8;
|
||||
const type = table.readUInt32LE(offset);
|
||||
const value = table.readUInt32LE(offset + 4);
|
||||
values.push(type === 1 ? { type: 'string', value: table.subarray(value, table.indexOf(0, value)).toString('ascii') } :
|
||||
{ type: 'integer', value });
|
||||
}
|
||||
H.assert.deepEqual(values, [{ type: 'string', value: '007' }, { type: 'integer', value: 7 }]);
|
||||
});
|
||||
|
||||
test('public pushdata build retains quoted "007" and integer 7 as data nodes and correctly typed CONST values', () => {
|
||||
const work = H.testCase('pushdata-types');
|
||||
const source = H.expanded(work, rawAction(`<pushdata action="150">
|
||||
<data val="0" str="007"/>
|
||||
<data val="1" num="7"/>
|
||||
</pushdata>`));
|
||||
H.assert.match(source.xml, /pushdata "007", 7/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
const body = rebuilt.raw.match(/<pushdata\b[^>]*>([\s\S]*?)<\/pushdata>/);
|
||||
H.assert.ok(body);
|
||||
const children = Array.from(body[1].matchAll(/<(data|constant)\b[^>]*>/g), match =>
|
||||
({ tag: match[1], attrs: H.attributes(match[0]) }));
|
||||
H.assert.equal(children.length, 2);
|
||||
H.assert.deepEqual(children.map(child => child.tag), ['data', 'data'],
|
||||
'xml2apt expects data children; constant children previously crashed the native converter');
|
||||
H.assert.equal(children[0].attrs.str, '007');
|
||||
H.assert.equal(children[0].attrs.num, undefined);
|
||||
H.assert.equal(Number(children[1].attrs.num), 7);
|
||||
H.assert.equal(children[1].attrs.str, undefined);
|
||||
const records = H.constRecords(rebuilt.output);
|
||||
H.assert.deepEqual(records.map(record => ({ type: record.type, value: record.value })),
|
||||
[{ type: 1, value: '007' }, { type: 7, value: 7 }]);
|
||||
H.assert.equal(Number(children[0].attrs.val), 0);
|
||||
H.assert.equal(Number(children[1].attrs.val), 1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<aptdata>
|
||||
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/>
|
||||
<frames>
|
||||
<frame id="0">
|
||||
<action>
|
||||
<noarg action="7"/>
|
||||
<noarg action="0"/>
|
||||
</action>
|
||||
<placeobject flags="126" depth="1" character="2" rotm00="0.85" rotm01="0.125" rotm10="-0.25" rotm11="1.25" tx="12.25" ty="-3.5" red="37" green="73" blue="211" alpha="0" unknown="0" ratio="0" clipdepth="8"><poname name="row"/></placeobject>
|
||||
<placeobject flags="46" depth="2" character="2" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="22.5" red="201" green="17" blue="61" alpha="127" unknown="67305985"><poname name="dimmed"/></placeobject>
|
||||
</frame>
|
||||
<frame id="1">
|
||||
<!-- cc3tools emits default expanded fields even when flags omit them. -->
|
||||
<placeobject flags="1" depth="1" character="-1" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="0" red="255" green="255" blue="255" alpha="255" unknown="0" ratio="0" clipdepth="-1"/>
|
||||
</frame>
|
||||
<frame id="2">
|
||||
<placeobject flags="81" depth="1" character="-1" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="0" red="255" green="255" blue="255" alpha="255" unknown="0" ratio="0" clipdepth="0"/>
|
||||
</frame>
|
||||
</frames>
|
||||
</movieclip>
|
||||
<font id="1" name="Lucida Sans"/>
|
||||
<edittext id="2" top="-2" left="-2" bottom="26" right="317" font="1" alignment="0" red="17" green="83" blue="201" alpha="137" height="18" readonly="1" multiline="0" wordwrap="0"><ettext text="A & B "menu""/><etvar variable=""/></edittext>
|
||||
</aptdata>
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const H = require('./public-cli-helper.cjs');
|
||||
const fixture = H.fs.readFileSync(H.path.join(__dirname, 'visual-state.raw.xml'), 'utf8');
|
||||
|
||||
test('public roundtrip retains an active null clip-action pointer without adding an array', () => {
|
||||
const work = H.testCase('empty-clip-actions');
|
||||
const raw = fixture.replace('flags="126"', 'flags="254"');
|
||||
H.assert.notEqual(raw, fixture);
|
||||
const source = H.expanded(work, raw);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', 1).flags), 254);
|
||||
H.assert.doesNotMatch(source.xml, /<clipactions\b/);
|
||||
H.assert.doesNotMatch(rebuilt.raw, /<clipactions\b/);
|
||||
});
|
||||
|
||||
test('public expand/build applies a height edit while retaining text RGBA and other text fields', () => {
|
||||
const work = H.testCase('text-height');
|
||||
const source = H.expanded(work, fixture);
|
||||
H.assert.deepEqual(['red', 'green', 'blue', 'alpha'].map(k => H.element(source.xml, 'edittext', 'id', 2)[k]),
|
||||
['17', '83', '201', '137']);
|
||||
const edited = source.xml.replace('height="18"', 'height="16"');
|
||||
H.assert.notEqual(edited, source.xml, 'the requested edit must actually alter the XML');
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const text = H.element(rebuilt.raw, 'edittext', 'id', 2);
|
||||
H.assert.equal(Number(text.height), 16);
|
||||
for (const [key, expected] of Object.entries({ red: '17', green: '83', blue: '201', alpha: '137',
|
||||
top: '-2', left: '-2', bottom: '26', right: '317', alignment: '0', readonly: '1', multiline: '0', wordwrap: '0' })) {
|
||||
H.assert.equal(text[key], expected, `${key} is unrelated to the height edit`);
|
||||
}
|
||||
H.assert.equal(H.element(rebuilt.raw, 'movieclip', 'id', 0).unknown, '33');
|
||||
H.assert.equal(H.element(rebuilt.raw, 'ettext', 'text', 'A & B "menu"').text, 'A & B "menu"');
|
||||
});
|
||||
|
||||
test('public build honors deliberately edited text RGB in the same contract as expand', () => {
|
||||
const work = H.testCase('text-color-edit');
|
||||
const source = H.expanded(work, fixture);
|
||||
const edited = source.xml.replace(/(<edittext\b[^>]*?)red="17" green="83" blue="201"/,
|
||||
'$1red="7" green="19" blue="233"');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const text = H.element(rebuilt.raw, 'edittext', 'id', 2);
|
||||
H.assert.deepEqual(['red', 'green', 'blue', 'alpha'].map(k => text[k]), ['7', '19', '233', '137']);
|
||||
H.assert.equal(Number(text.height), 18);
|
||||
});
|
||||
|
||||
test('public roundtrip retains transparent/dim color transforms and additive color values', () => {
|
||||
const work = H.testCase('placement-color');
|
||||
const source = H.expanded(work, fixture);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
for (const depth of [1, 2]) {
|
||||
const before = H.element(H.frame(fixture, 0), 'placeobject', 'depth', depth);
|
||||
const after = H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', depth);
|
||||
H.assert.equal(Number(after.flags), Number(before.flags));
|
||||
H.assert.ok(Number(after.flags) & 8, 'HasColorTransform is semantically active');
|
||||
for (const key of ['red', 'green', 'blue', 'alpha', 'unknown']) {
|
||||
H.assert.equal(after[key], before[key], `depth ${depth}: preserve ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('public roundtrip retains Move-only and active zero ratio/clipdepth without inventing matrix/color state', () => {
|
||||
const work = H.testCase('move-flags');
|
||||
const source = H.expanded(work, fixture);
|
||||
const move = H.element(H.frame(source.xml, 1), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(move.flags, 'Move');
|
||||
for (const key of ['matrix', 'rotm00', 'rotm01', 'rotm10', 'rotm11', 'tx', 'ty',
|
||||
'rgbamul', 'rgbaadd', 'red', 'green', 'blue', 'alpha', 'unknown']) {
|
||||
H.assert.equal(move[key], undefined, `inactive raw dump field ${key} must not become an authored edit`);
|
||||
}
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(H.frame(rebuilt.raw, 1), 'placeobject', 'depth', 1).flags), 1);
|
||||
const zero = H.element(H.frame(rebuilt.raw, 2), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(Number(zero.flags), 81);
|
||||
H.assert.equal(Number(zero.ratio), 0);
|
||||
H.assert.equal(Number(zero.clipdepth), 0);
|
||||
});
|
||||
|
||||
test('public build applies one translation edit while retaining the nonidentity matrix, ratio and clipping contract', () => {
|
||||
const work = H.testCase('matrix-edit');
|
||||
const source = H.expanded(work, fixture);
|
||||
const edited = source.xml.replace('tx="12.25"', 'tx="32.25"');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const placement = H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(Number(placement.tx), 32.25);
|
||||
H.assert.equal(Number(placement.flags), 126);
|
||||
H.assert.deepEqual(['rotm00', 'rotm01', 'rotm10', 'rotm11', 'ty'].map(k => Number(placement[k])),
|
||||
[0.85, 0.125, -0.25, 1.25, -3.5]);
|
||||
H.assert.equal(Number(placement.ratio), 0);
|
||||
H.assert.equal(Number(placement.clipdepth), 8);
|
||||
});
|
||||
Reference in New Issue
Block a user