first commit

This commit is contained in:
2026-08-01 14:00:17 +02:00
commit 130f8b4c1d
60 changed files with 9324 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseVehicle" Side="Allies" />
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="SageStaticMarker" />
</AssetDeclaration>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="VanillaTank" />
<WeaponTemplate id="VanillaCannon" />
</AssetDeclaration>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset"></AssetDeclaration>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="SageXml/Vanilla.xml" />
</Includes>
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseVehicle" Side="Allies" />
</AssetDeclaration>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<HeadlightDraw2 id="FragLight">
<Model Name="frag.mdl" />
</HeadlightDraw2>
</AssetDeclaration>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<!-- Same id on two different asset types: navigation must filter by the
attribute's reference type. -->
<WeaponTemplate id="SharedThing" />
<GameObject id="SharedThing" />
<!-- Nested xi:include: content is inlined into the parent element. -->
<GameObject id="XiNestedGameObject">
<Draws>
<xi:include
href="DATA:Includes/Fragment.xml"
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)" />
<xi:include href="DATA:Missing/Nested.xml" />
</Draws>
</GameObject>
</AssetDeclaration>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Texture id="TestTexture" File="test.dds" />
</AssetDeclaration>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Defines>
<Define name="TEST_HEALTH" value="100.0" />
</Defines>
<GameObject
id="TestTank"
inheritFrom="BaseVehicle"
CommandSet="TestTankCommandSet"
KindOf="SELECTABLE">
</GameObject>
<GameObject
id="TestTank"
Side="Allies">
</GameObject>
<Includes>
<Include type="instance" source="BaseVehicle.xml" />
</Includes>
</AssetDeclaration>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<WeaponTemplate id="TestTankCannon" AttackRange="100.0" />
<LogicCommandSet id="TestTankCommandSet" />
<Includes>
<Include type="all" source="Missing/File.xml" />
</Includes>
</AssetDeclaration>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Tags />
<Includes>
<Include type="reference" source="DATA:static.xml" />
<Include type="reference" source="DATA:global.xml" />
<Include type="reference" source="DATA:audio.xml" />
<Include type="all" source="Includes/Units.xml" />
<Include type="all" source="Includes/Weapons.xml" />
<Include type="all" source="Includes/Shared.xml" />
<Include type="all" source="Includes/Refs.xml" />
</Includes>
</AssetDeclaration>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<MpGameRules id="TheMpGameRules">
<SkirmishStartCash LoCash="10000" HiCash="50000" />
</MpGameRules>
</AssetDeclaration>
+89
View File
@@ -0,0 +1,89 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
buildSearchPaths,
resolveSource,
manifestPathForReference,
} from "../out/indexer/includeResolver.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
const paths = buildSearchPaths(sdk, project);
test("resolves relative includes against the current file directory", () => {
const current = join(project, "Data", "Includes");
const r = resolveSource("BaseVehicle.xml", current, paths);
assert.equal(r.path, join(project, "Data", "Includes", "BaseVehicle.xml"));
});
test("resolves DATA: includes through search paths", () => {
const r = resolveSource("DATA:static.xml", null, paths);
assert.equal(r.path, join(sdk, "static.xml"));
const r2 = resolveSource("DATA:SageXml/Vanilla.xml", null, paths);
assert.equal(r2.path, join(sdk, "SageXml", "Vanilla.xml"));
});
test("case-insensitive prefix", () => {
const r = resolveSource("data:static.xml", null, paths);
assert.equal(r.path, join(sdk, "static.xml"));
});
test("missing target returns null", () => {
const r = resolveSource("DATA:nope/nothere.xml", null, paths);
assert.equal(r.path, null);
assert.equal(r.prefix, "DATA");
});
test("search paths follow the BinaryAssetBuilder /data /art /audio order", () => {
// defaultscript.cs getIncludePaths():
// /data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
// /art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
// /audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
// where "." = sdkDir, {0} = projectDir, {1} = modParentPath,
// {2} = modGranParent.
const modParent = dirname(project);
const granParent = dirname(modParent);
assert.deepEqual(paths.DATA, [
sdk,
granParent,
join(project, "Data"),
join(sdk, "Mods"),
modParent,
join(sdk, "SageXml"),
]);
assert.deepEqual(paths.ART, [
sdk,
granParent,
join(project, "Art1"),
join(project, "Art"),
join(sdk, "Mods"),
modParent,
join(sdk, "Art"),
]);
assert.deepEqual(paths.AUDIO, [
sdk,
granParent,
join(project, "Audio1"),
join(project, "Audio"),
join(sdk, "Mods"),
modParent,
join(sdk, "Audio"),
]);
});
test("DATA:Static.xml prefers the SDK root over SageXml", () => {
// Both the SDK root and SageXml contain a Static.xml; BAB order resolves to
// the SDK root placeholder first.
const r = resolveSource("DATA:Static.xml", null, paths);
assert.equal(r.path, join(sdk, "Static.xml"));
});
test("manifest mapping strips the prefix", () => {
const dirs = [join(sdk, "builtmods")];
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
assert.equal(manifestPathForReference("DATA:audio.xml", dirs), join(dirs[0], "audio.manifest"));
assert.equal(manifestPathForReference("DATA:missing.xml", dirs), null);
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
async function buildIndex() {
const indexer = new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
return indexer.build();
}
test("indexes assets, defines, streams and include errors", async () => {
const idx = await buildIndex();
assert.equal(idx.stats.streams, 2); // static + global
assert.ok(idx.assetsById.has("testtank"));
assert.ok(idx.assetsById.has("testtankcannon"));
assert.ok(idx.assetsById.has("testtankcommandset"));
assert.ok(idx.assetsById.has("testtexture"));
assert.ok(idx.assetsById.has("thempgamerules"));
assert.ok(idx.assetsById.has("vanillatank"), "vanilla data via reference include fallback");
assert.ok(idx.assetsById.has("basevehicle"));
const baseVehicle = idx.assetsById.get("basevehicle");
assert.ok(baseVehicle.some((d) => d.viaInstance), "instance-included asset marked");
const defs = idx.defines.get("test_health");
assert.equal(defs?.[0].value, "100.0");
const missing = idx.diagnostics.find((d) => d.code === "include-not-found");
assert.ok(missing, "missing include reported");
assert.match(missing.message, /Missing\/File\.xml/);
// Nested xi:include: existing target is walked, missing target is reported.
assert.ok(
idx.diagnostics.some((d) => /Missing\/Nested\.xml/.test(d.message)),
"nested missing xi:include reported",
);
assert.ok(
idx.files.has(
join(project, "Data", "Includes", "Fragment.xml").toLowerCase(),
),
"nested xi:include target indexed",
);
assert.ok(
idx.assetsById.has("fraglight"),
"nested xi:include target assets available",
);
const staticStream = idx.streams.find((s) => s.name === "static");
assert.ok(staticStream.files.size >= 4);
});
test("provides include source candidates", async () => {
const idx = await buildIndex();
const xml = idx.sourceCandidates.filter((c) => c.source.endsWith(".xml"));
assert.ok(xml.length >= 4);
assert.ok(xml.some((c) => c.source === "Includes/Units.xml"));
assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseManifest } from "../out/indexer/manifestParser.js";
function u32(v) {
const b = Buffer.alloc(4);
b.writeUInt32LE(v >>> 0);
return b;
}
function u16(v) {
const b = Buffer.alloc(2);
b.writeUInt16LE(v >>> 0);
return b;
}
test("parses a minimal version 5 manifest", () => {
const name = Buffer.from("VanillaTank\0", "ascii");
const src = Buffer.from("DATA:globaldata/armor.xml\0", "ascii");
const parts = [
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true (one byte each)
u16(5), // version
u32(0x1234), // streamChecksum
u32(0), // allTypesHash
u32(1), // assetCount
u32(0), // totalInstanceDataSize
u32(0), // maxInstanceChunkSize
u32(0), // maxRelocationChunkSize
u32(0), // maxImportsChunkSize
u32(0), // assetReferenceBufferSize
u32(0), // referencedManifestNameBufferSize
u32(name.length), // assetNameBufferSize
u32(src.length), // sourceFileNameBufferSize
// one asset entry (version 5 = 44 bytes, no tokenized flag)
u32(0x942fff2d), // typeId (GameObject)
u32(0), // instanceId
u32(0), // typeHash
u32(0), // instanceHash
u32(0), // assetReferenceOffset
u32(0), // assetReferenceCount
u32(0), // nameOffset
u32(0), // sourceFileNameOffset
u32(0), // instanceDataSize
u32(0), // relocationDataSize
u32(0), // importsDataSize
name,
src,
];
const info = parseManifest(new Uint8Array(Buffer.concat(parts)));
assert.equal(info.version, 5);
assert.equal(info.assetCount, 1);
assert.equal(info.assets.length, 1);
assert.equal(info.assets[0].name, "VanillaTank");
assert.equal(info.assets[0].typeName, "GameObject");
assert.equal(info.assets[0].sourceFileName, "DATA:globaldata/armor.xml");
});
test("reports an error for garbage input", () => {
const info = parseManifest(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]));
assert.ok(info.error);
assert.equal(info.assets.length, 0);
});
+83
View File
@@ -0,0 +1,83 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { deriveAssetId, deriveAssetType } from "../out/indexer/manifestParser.js";
import { isAssignableTo } from "../out/model/schemaModel.js";
import {
isReferenceAttributeOfType,
resolveReferenceTargetsForType,
} from "../out/indexer/refs.js";
test("deriveAssetType falls back to the TypeName:Id prefix", () => {
assert.equal(deriveAssetType(null, "PlayerTemplate:Allies"), "PlayerTemplate");
assert.equal(deriveAssetType(null, "AudioFile:A06_Loop"), "AudioFile");
assert.equal(deriveAssetType("GameObject", "GameObject:X"), "GameObject");
assert.equal(deriveAssetType(null, "NoColonHere"), null);
assert.equal(deriveAssetType(null, ":LeadingColon"), null);
});
test("deriveAssetId uses the last colon segment", () => {
// Art assets carry an extra subtype segment: Type:SubType:FileName.
assert.equal(
deriveAssetId("W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN"),
"AUANTIVEHICLEVEHICLETECH1_SKN",
);
assert.equal(deriveAssetId("PlayerTemplate:Allies"), "Allies");
assert.equal(deriveAssetId("AudioFile:A06_Loop"), "A06_Loop");
assert.equal(deriveAssetId("Texture:Texture:AUAntiVehicleVehicleTech1"), "AUAntiVehicleVehicleTech1");
assert.equal(deriveAssetId("NoColon"), "NoColon");
});
test("case-normalized type matching follows the XSD inheritance chain", () => {
// Manifest hashes use "W3d*" casing while the XSD types are "W3D*"; the
// canonical lookup must bridge the two and respect the real chain.
assert.ok(
isAssignableTo("W3dContainer", "BaseRenderAssetType"),
"W3dContainer is a render asset",
);
assert.ok(
isAssignableTo("W3DMesh", "BaseRenderAssetType"),
"W3DMesh is a render asset",
);
assert.ok(
!isAssignableTo("W3dHierarchy", "BaseRenderAssetType"),
"W3dHierarchy is NOT a render asset (per XSD)",
);
});
test("manifest-derived PlayerTemplate ids satisfy Side references", () => {
// Simulate a manifest asset whose TypeId hash is unknown but whose name
// carries the type prefix (exactly what global.manifest does for
// PlayerTemplate:Allies).
const idx = {
assetsById: new Map([
[
"allies",
[
{
type: "PlayerTemplate",
id: "Allies",
file: "global.manifest",
line: 0,
origin: "manifest",
},
],
],
]),
assets: new Map([["PlayerTemplate", new Map([["allies", []]])]]),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
assert.ok(isAssignableTo("PlayerTemplate", "PlayerTemplate"));
assert.equal(isReferenceAttributeOfType("GameObject", "Side"), true);
const targets = resolveReferenceTargetsForType(idx, "GameObject", "Side", "Allies");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "PlayerTemplate");
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
import { isReferenceAttribute, resolveReferenceTargets } from "../out/indexer/refs.js";
import * as model from "../out/model/schemaModel.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
const sdk = join(root, "test", "fixtures", "fakesdk");
async function buildIndex() {
const indexer = new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
return indexer.build();
}
test("definition resolution filters by the attribute's ref type", async () => {
const idx = await buildIndex();
const defs = idx.assetsById.get("sharedthing");
assert.equal(defs.length, 2);
const types = defs.map((d) => d.type).sort();
assert.deepEqual(types, ["GameObject", "WeaponTemplate"]);
// WeaponName="SharedThing" on a WeaponTemplateRef attribute must only resolve
// to the WeaponTemplate definition.
const targets = resolveReferenceTargets(idx, "FireWeaponNugget", "WeaponName", "SharedThing");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "WeaponTemplate");
assert.match(targets[0].def.file, /Refs\.xml/);
// A non-reference attribute must never resolve anything.
const none = resolveReferenceTargets(idx, "FireWeaponNugget", "EditorName", "SharedThing");
assert.equal(none.length, 0);
// inheritFrom filters to types assignable to the element itself.
const inherit = resolveReferenceTargets(idx, "GameObject", "inheritFrom", "SharedThing");
assert.equal(inherit.length, 1);
assert.equal(inherit[0].def.type, "GameObject");
});
test("the model knows a real WeaponTemplateRef attribute pair", () => {
const attrs = model.attributesOfElement("FireWeaponNugget");
const weapon = attrs.find((a) => a.name === "WeaponName");
assert.equal(weapon?.refType, "WeaponTemplate");
});
test("isReferenceAttribute distinguishes references from enums/paths", () => {
// Include/@type and Include/@source must never be treated as references.
assert.equal(isReferenceAttribute("Include", "type"), false);
assert.equal(isReferenceAttribute("Include", "source"), false);
// Typed references and inheritFrom are references.
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
});
test("untyped references (isRef without refType) resolve to any declared id", () => {
// LocomotorSet/@Locomotor has type AssetReference: xas:isRef="true" with
// no refType. It must be treated as a reference and match the id regardless
// of the target asset type.
const idx = {
assetsById: new Map([
[
"alliedantivehiclevehicletech1locomotor",
[
{
type: "LocomotorTemplate",
id: "AlliedAntiVehicleVehicleTech1Locomotor",
file: "Locomotor.xml",
line: 5,
origin: "project",
},
],
],
]),
assets: new Map(),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
assert.equal(isReferenceAttribute("LocomotorSet", "Locomotor"), true);
const targets = resolveReferenceTargets(idx, "LocomotorSet", "Locomotor", "AlliedAntiVehicleVehicleTech1Locomotor");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "LocomotorTemplate");
// A non-reference attribute still returns nothing.
assert.equal(resolveReferenceTargets(idx, "LocomotorSet", "EditorName", "X").length, 0);
});
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as model from "../out/model/schemaModel.js";
test("top-level elements include core asset types", () => {
for (const name of ["GameObject", "WeaponTemplate", "Texture", "LogicCommandSet", "SpecialPowerTemplate"]) {
assert.ok(model.isTopLevelElement(name), name);
}
});
test("GameObject has expected attributes", () => {
const attrs = model.attributesOfElement("GameObject");
const id = attrs.find((a) => a.name === "id");
assert.ok(id?.required);
const cmd = attrs.find((a) => a.name === "CommandSet");
assert.equal(cmd?.refType, "LogicCommandSet");
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
});
test("Include has reference/instance/all enum", () => {
const attrs = model.attributesOfElement("Include");
const type = attrs.find((a) => a.name === "type");
assert.deepEqual(type?.enumValues, ["reference", "instance", "all"]);
});
test("type assignability follows inheritance", () => {
assert.ok(model.isAssignableTo("GameObject", "BaseInheritableAsset"));
assert.ok(model.isAssignableTo("GameObject", "GameObject"));
assert.ok(!model.isAssignableTo("WeaponTemplate", "GameObject"));
});
test("asset type hashes resolve", () => {
assert.equal(model.assetTypeNameFromHash(0x942fff2d), "GameObject");
assert.equal(model.assetTypeNameFromHash(0x166b084d), "AudioFile");
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml } from "../out/language/xmlParser.js";
import { resolveElementType } from "../out/language/typeContext.js";
import * as model from "../out/model/schemaModel.js";
test("context-aware type resolution follows the parent chain", () => {
const text = `<?xml version="1.0"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Tank">
<BehaviorModules>
<WeaponSetUpdate>
<WeaponSlotTurret ID="1">
<Weapon Ordering="PRIMARY_WEAPON" />
</WeaponSlotTurret>
</WeaponSetUpdate>
</BehaviorModules>
</GameObject>
</AssetDeclaration>`;
const doc = parseXml(text);
const weapon = doc.elements.find((e) => e.name === "Weapon");
assert.ok(weapon, "Weapon element parsed");
const weaponType = resolveElementType(weapon);
assert.equal(weaponType, "WeaponSlot_WeaponData");
const attrs = model.attributesOfType(weaponType);
assert.ok(attrs.some((a) => a.name === "Ordering"), "Ordering attribute present");
// The same element name in a non-slot context resolves differently
// (e.g. a plain WeaponRef) and must not leak the slot attributes.
const slot = doc.elements.find((e) => e.name === "WeaponSlotTurret");
assert.equal(resolveElementType(slot), "WeaponSlot_Turret");
});
test("model childTypeOf primitives", () => {
assert.equal(model.childTypeOf("WeaponSetUpdateModuleData", "WeaponSlotTurret"), "WeaponSlot_Turret");
assert.equal(model.childTypeOf("WeaponSlot_Turret", "Weapon"), "WeaponSlot_WeaponData");
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
assert.equal(model.childTypeOf(null, "Weapon"), null);
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml, findElementAt } from "../out/language/xmlParser.js";
test("parses elements, attributes and positions", () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;
const doc = parseXml(text);
assert.equal(doc.errors.length, 0);
assert.equal(doc.root.name, "AssetDeclaration");
const go = doc.root.children[0];
assert.equal(go.name, "GameObject");
assert.equal(go.selfClosing, true);
assert.equal(go.attrs.length, 2);
assert.equal(go.attrs[0].name, "id");
assert.equal(go.attrs[0].value, "X");
assert.equal(go.attrs[1].value, "A B");
const idAttr = go.attrs[0];
assert.equal(text.slice(idAttr.valueStart, idAttr.valueEnd), "X");
});
test("detects mismatched and unclosed tags", () => {
const doc = parseXml("<A><B></A>");
assert.ok(doc.errors.length >= 1);
assert.match(doc.errors[0].message, /Mismatched|never closed/);
});
test("handles CRLF and comments", () => {
const text = "<?xml version=\"1.0\"?>\r\n<!-- hello -->\r\n<AssetDeclaration>\r\n</AssetDeclaration>\r\n";
const doc = parseXml(text);
assert.equal(doc.errors.length, 0);
assert.equal(doc.root.name, "AssetDeclaration");
});
test("findElementAt returns innermost element", () => {
const text = `<A><B id="1"><C/></B></A>`;
const doc = parseXml(text);
const c = doc.elements.find((e) => e.name === "C");
const at = findElementAt(doc, c.start + 1);
assert.equal(at.name, "C");
});
test("tolerates partial input while typing", () => {
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
const doc = parseXml(text);
assert.ok(doc.errors.length >= 1); // unclosed
assert.equal(doc.elements.length, 2);
});