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,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 };
|
||||
Reference in New Issue
Block a user