fix inherit from

This commit is contained in:
2026-08-11 19:42:23 +02:00
parent b5e055216c
commit 84a44bedfd
26 changed files with 966 additions and 67 deletions
+84
View File
@@ -308,6 +308,21 @@ test("element and attribute name completions work without an index", async () =>
assert.ok(labels.includes("Surfaces"));
});
test("universal inheritFrom is offered on asset attribute completion", async () => {
const text = `<AssetDeclaration>\n <FXList `;
const line1 = text.split("\n")[1];
const pos = new Position(1, line1.length);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = listItems(items).map((i) => i.label);
assert.ok(labels.includes("inheritFrom"), "FXList offers universal inheritFrom");
assert.ok(labels.includes("id"));
});
test("attribute completion after a closed quote inserts a space", async () => {
const text =
`<AssetDeclaration>\n` +
@@ -762,6 +777,75 @@ test("simple-content value completion works before the closing tag is typed", as
assert.equal(item.range.end.character, pos.character);
});
test("simpleContent complex child inserts a value pair and triggers suggest", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <S`;
const line = text.split("\n")[2];
const pos = new Position(2, line.length);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const sound = listItems(items).find((i) => i.label === "Sound");
assert.ok(sound, "Sound child is offered under AudioEvent");
assert.equal(sound.insertText.value, "Sound>$1</Sound>");
assert.ok(sound.command, "simpleContent child re-triggers value suggest");
});
test("simpleContent complex text offers typed asset ids as the value", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>V</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf(">V") + 2);
const audioFile = {
type: "AudioFile",
id: "VoiceFile",
file: "AudioFiles.xml",
line: 1,
origin: "project",
};
const audioEvent = {
type: "AudioEvent",
id: "VoiceEvent",
file: "Voice.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([
["AudioFile", new Map([["voicefile", [audioFile]]])],
["AudioEvent", new Map([["voiceevent", [audioEvent]]])],
]),
assetsById: new Map([
["voicefile", [audioFile]],
["voiceevent", [audioEvent]],
]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("VoiceFile"));
assert.ok(
!labels.includes("VoiceEvent"),
"Sound content is filtered by AudioFileRefWithWeight refType AudioFile",
);
const item = items.find((i) => i.label === "VoiceFile");
assert.equal(item.range.start.character, line.indexOf(">V") + 1);
assert.equal(item.range.end.character, pos.character);
});
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
const text =
`<AssetDeclaration>\n` +
+179
View File
@@ -239,6 +239,88 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
);
});
test("hover on simpleContent complex content shows the referenced definition", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>VoiceFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioFile",
id: "VoiceFile",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
const provider = new Ra3HoverProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
searchPaths: () => null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
const hover = await provider.provideHover(makeDocument(text), pos, {});
assert.ok(hover, "hover is returned for AudioFileRefWithWeight content");
assert.match(hover.contents.value, /1 definition/);
assert.match(hover.contents.value, /AudioFile/);
});
test("Ctrl+click on simpleContent complex content jumps to the definition", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound>VoiceFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioFile",
id: "VoiceFile",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1);
assert.equal(locations[0].uri.fsPath, URI);
});
test("diagnostics report unresolved simpleContent complex content references", async () => {
const text =
`<AssetDeclaration>\n` +
` <Multisound id="M">\n` +
` <Subsound>MissingEvent</Subsound>\n` +
` </Multisound>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: false,
reportUnresolvedReferences: "warning",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const messages = collection.last.diags.map((d) => d.message);
assert.ok(
messages.some((m) => m.includes('Unresolved reference "MissingEvent"')),
"MultisoundSubsoundRef text is diagnosed as a typed content reference",
);
});
test("Ctrl+click on a manifest definition maps to SageXml even when the mod shadows the DATA path", async () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-"));
try {
@@ -518,6 +600,103 @@ test("fragment diagnostics ignore unknown wrapper roots and still report missing
);
});
test("diagnostics accept universal inheritFrom on asset types", async () => {
const text =
`<AssetDeclaration>\n` +
` <FXList id="FX_A" inheritFrom="FX_Base">\n` +
` <NuggetList/>\n` +
` </FXList>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"FXList inheritFrom must not be flagged as unknown",
);
const badText =
`<AssetDeclaration>\n` +
` <FXList id="FX_B" Bogus="x">\n` +
` <NuggetList/>\n` +
` </FXList>\n` +
`</AssetDeclaration>`;
const badScope = await makeScope(badText, makeIdx([]));
const badCollection = new FakeDiagnosticCollection();
const badProvider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => badScope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
badProvider["collection"] = badCollection;
await badProvider.update(makeDocument(badText));
assert.ok(
badCollection.last.diags.map((d) => d.code).includes("unknown-attribute"),
"a real unknown attribute is still reported",
);
});
test("diagnostics keep simpleContent extension attributes known", async () => {
const text =
`<AssetDeclaration>\n` +
` <AudioEvent id="A">\n` +
` <Sound Weight="100">AudioFile</Sound>\n` +
` </AudioEvent>\n` +
`</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"Weight on AudioFileRefWithWeight must be known",
);
});
test("diagnostics use the top-level asset type for colliding fragment roots", async () => {
const text =
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`;
const scope = await makeScope(text, makeIdx([]));
const collection = new FakeDiagnosticCollection();
const provider = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: true,
reportUnresolvedReferences: "none",
},
});
provider["collection"] = collection;
await provider.update(makeDocument(text));
const codes = collection.last.diags.map((d) => d.code);
assert.ok(
!codes.includes("unknown-attribute"),
"EvaEvent fragment root attributes must resolve against the top-level asset type",
);
});
test("full documents still require ids on top-level assets", async () => {
const text = `<AssetDeclaration>\n <GameObject/>\n</AssetDeclaration>`;
const scope = await makeScope(text, makeIdx([]));
+23
View File
@@ -113,3 +113,26 @@ test("extractIndexRecords records typed references and skips non-references", ()
false,
);
});
test("extractIndexRecords records simpleContent complex text as references", () => {
const text = `<AssetDeclaration>
<AudioEvent id="A">
<Sound Weight="100">VoiceFile</Sound>
</AudioEvent>
<Multisound id="M">
<Subsound Weight="50">VoiceEvent</Subsound>
</Multisound>
</AssetDeclaration>`;
const lineMap = new LineMap(text);
const records = extractIndexRecords(parseXml(text), lineMap, text);
const content = records.references.filter((r) => r.kind === "content");
const sound = content.find((r) => r.value === "VoiceFile");
assert.ok(sound, "Sound text is recorded as a content reference");
assert.equal(sound.refType, "AudioFile");
assert.equal(sound.selfType, null);
const subsound = content.find((r) => r.value === "VoiceEvent");
assert.ok(subsound, "Subsound text is recorded as a content reference");
assert.equal(subsound.refType, "BaseAudioEventInfo");
});
+77
View File
@@ -177,3 +177,80 @@ test("FAR from the reference site itself returns the same result", async () => {
assert.equal(refs.length, 1);
assert.equal(refs[0].range.start.line, 4);
});
test("FAR includes simpleContent complex content references", async () => {
const text = `<AssetDeclaration>
<AudioFile id="VoiceFile"/>
<AudioEvent id="A">
<Sound>VoiceFile</Sound>
</AudioEvent>
</AssetDeclaration>`;
const parse = parseXml(text);
const lineMap = new LineMap(text);
const records = extractIndexRecords(parse, lineMap, text);
const def = {
type: "AudioFile",
id: "VoiceFile",
file: FILE,
line: 2,
origin: "project",
};
const lookup = {
assets: new Map([["AudioFile", new Map([["voicefile", [def]]])]]),
assetsById: new Map([["voicefile", [def]]]),
};
const references = buildReferenceIndex([{ file: FILE, records }], lookup);
const idx = {
...lookup,
references,
complete: true,
phase: "art",
projectDir: "C:/mod",
sdkDir: "C:/sdk",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
const scope = { merged: idx };
const localParse = parseXml(text);
const localLineMap = new LineMap(text);
const localIndexer = {
readDom: async (path) =>
path === FILE
? { file: { path: FILE }, parse: localParse, lineMap: localLineMap, records: null }
: null,
};
const localWs = {
isRa3Workspace: () => true,
getScope: async () => scope,
indexer: localIndexer,
indexerForFile: () => localIndexer,
activeIndexer: () => localIndexer,
recordsSyncSurfaceFor: () => ({
get index() {
return scope.merged;
},
invalidate: () => {},
scheduleRebuild: () => {},
}),
};
const provider = new Ra3ReferenceProvider(localWs);
const document = makeDocument(text);
const defLine = text.split("\n")[1];
const defPos = new Position(1, defLine.indexOf('id="') + 4);
const refs = await provider.provideReferences(document, defPos, {
includeDeclaration: true,
}, {});
assert.ok(refs, "references are returned");
assert.equal(refs.length, 1);
assert.equal(refs[0].range.start.line, 3);
assert.equal(
text.split("\n")[3].slice(refs[0].range.start.character, refs[0].range.end.character),
"VoiceFile",
);
});
+59
View File
@@ -9,6 +9,7 @@ import {
isReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
isReferenceTargetType,
resolveContentReferenceTargets,
resolveReferenceTargets,
resolveReferenceTargetsForType,
@@ -70,7 +71,25 @@ test("isReferenceAttribute distinguishes references from enums/paths", () => {
// Typed references and inheritFrom are references.
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FXList", "inheritFrom"), true);
assert.equal(isReferenceAttribute("AIMicroManagerData", "inheritFrom"), true);
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
// inheritFrom is an asset-level attribute; non-asset elements stay non-refs.
assert.equal(isReferenceAttribute("Include", "inheritFrom"), false);
});
test("universal inheritFrom legality is separate from CodeLens target design", () => {
// FXList and other BaseAssetType descendants legally accept inheritFrom,
// but the XSD does not declare it there. That must not widen the designed
// reference-target set (Credits is still not a CodeLens target).
assert.ok(
model.attributesOfElement("FXList").some((a) => a.name === "inheritFrom"),
);
assert.ok(
model.attributesOfElement("Credits").some((a) => a.name === "inheritFrom"),
);
assert.equal(isReferenceTargetType("Credits"), false);
assert.equal(isReferenceTargetType("FXList"), true); // via FXListRef, not universal attr
});
test("attribute-level xas:refType is preserved in the model", () => {
@@ -287,6 +306,46 @@ test("typed simple content resolves like a typed attribute reference", () => {
assert.equal(targets[0].def.type, "GameObject");
});
test("simpleContent complex types resolve as typed content references", () => {
// <Sound>AudioFile</Sound> / <Subsound>VoiceEvent</Subsound> use
// simpleContent complex types (AudioFileRefWithWeight /
// MultisoundSubsoundRef) whose text is still a typed asset reference.
assert.equal(isReferenceContentType("AudioFileRefWithWeight"), true);
assert.equal(isReferenceContentType("MultisoundSubsoundRef"), true);
// Inline Frame's simpleContent is a scalar float, not a reference.
assert.equal(isReferenceContentType("@inline:Frame"), false);
const idx = {
assetsById: new Map([
[
"shared",
[
{ type: "AudioFile", id: "Shared", file: "Audio.xml", line: 1, origin: "project" },
{ type: "AudioEvent", id: "Shared", file: "Voice.xml", line: 2, origin: "project" },
],
],
]),
assets: new Map(),
defines: new Map(),
};
const soundTargets = resolveContentReferenceTargets(
idx,
"AudioFileRefWithWeight",
"Shared",
);
assert.equal(soundTargets.length, 1);
assert.equal(soundTargets[0].def.type, "AudioFile");
const subsoundTargets = resolveContentReferenceTargets(
idx,
"MultisoundSubsoundRef",
"Shared",
);
assert.equal(subsoundTargets.length, 1);
assert.equal(subsoundTargets[0].def.type, "AudioEvent");
});
test("untyped and pipeline-local content is not a global reference", () => {
// Generic AssetReference content is used for shader constants and model
// sub-object names, not global asset ids; Poid is pipeline-local.
+64
View File
@@ -17,6 +17,70 @@ test("GameObject has expected attributes", () => {
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
});
test("inheritFrom is a universal asset attribute, not only BaseInheritableAsset", () => {
// BAB / vanilla data accepts inheritFrom on FXList, AIMicroManagerData,
// ObjectCreationList, OnDemandTextureImage and AITargetingHeuristic even
// though the XSD only declares it on BaseInheritableAsset.
for (const name of [
"FXList",
"AIMicroManagerData",
"ObjectCreationList",
"OnDemandTextureImage",
"AITargetingHeuristic",
]) {
assert.ok(
model.attributesOfElement(name).some((a) => a.name === "inheritFrom"),
`${name} should accept universal inheritFrom`,
);
}
// Structural elements that are not assets must not get the attribute.
assert.ok(
!model.attributesOfElement("Include").some((a) => a.name === "inheritFrom"),
"Include is not an asset and must not accept inheritFrom",
);
});
test("simpleContent extension attributes are preserved by the model generator", () => {
const sound = model.typeInfo("AudioFileRefWithWeight");
assert.equal(sound?.kind, "complex");
assert.ok(sound.attributes.some((a) => a.name === "Weight"));
assert.ok(sound.attributes.some((a) => a.name === "Volume"));
const subsound = model.typeInfo("MultisoundSubsoundRef");
assert.equal(subsound?.kind, "complex");
assert.ok(subsound.attributes.some((a) => a.name === "Weight"));
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftLow"));
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftHigh"));
assert.ok(subsound.attributes.some((a) => a.name === "Volume"));
assert.ok(subsound.attributes.some((a) => a.name === "PlayPercent"));
assert.ok(subsound.attributes.some((a) => a.name === "VolumeShift"));
});
test("contentInfoOfType unifies simple and simpleContent content semantics", () => {
const simple = model.contentInfoOfType("GameObjectWeakRef");
assert.equal(simple?.kind, "simple");
assert.equal(simple?.refType, "GameObject");
const sound = model.contentInfoOfType("AudioFileRefWithWeight");
assert.equal(sound?.kind, "simpleContent");
assert.equal(sound?.refType, "AudioFile");
assert.equal(sound?.isRef, true);
const subsound = model.contentInfoOfType("MultisoundSubsoundRef");
assert.equal(subsound?.kind, "simpleContent");
assert.equal(subsound?.refType, "BaseAudioEventInfo");
// Ordinary complex elements and structural elements have no content value.
assert.equal(model.contentInfoOfType("GameObject"), null);
assert.equal(model.contentInfoOfType("Include"), null);
// Inline simpleContent with a scalar base has content info but no refType.
const frame = model.contentInfoOfType("@inline:Frame");
assert.ok(frame, "inline simpleContent is exposed through contentInfoOfType");
assert.equal(frame?.refType, null);
assert.equal(frame?.base, "float");
});
test("attribute-level xas:refType is captured (module ids, map objects)", () => {
// ModuleData@id is declared as <xs:attribute name="id" type="Poid"
// xas:refType="ModuleData" />; the refType must reach every module subtype.
+21
View File
@@ -39,3 +39,24 @@ test("model childTypeOf primitives", () => {
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
assert.equal(model.childTypeOf(null, "Weapon"), null);
});
test("fragment roots prefer top-level AssetDeclaration types over name collisions", () => {
// <EvaEvent> is both a top-level asset and an FXNugget child. A fragment
// root has no parent context, so it must resolve to the top-level asset
// type (with Priority / TimeBetweenEvents etc.), not to EvaEventFXNugget.
const evaDoc = parseXml(
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`,
);
assert.equal(resolveElementType(evaDoc.root), "EvaEvent");
assert.ok(
model.attributesOfType("EvaEvent").some((a) => a.name === "Priority"),
);
const upgradeDoc = parseXml(`<UpgradeTemplate id="Upgrade_X" inheritFrom="Base"/>`);
assert.equal(resolveElementType(upgradeDoc.root), "UpgradeTemplate");
// Non-top-level fragment roots still fall back to the contextual child
// mapping (no AssetDeclaration child exists for Weapon).
const weaponDoc = parseXml(`<Weapon Ordering="PRIMARY_WEAPON"/>`);
assert.equal(resolveElementType(weaponDoc.root), "WeaponRef");
});