0.1.15 继续优化补全

This commit is contained in:
2026-08-04 19:14:12 +02:00
parent f8187da611
commit 47807f9fed
17 changed files with 1762 additions and 87 deletions
+391
View File
@@ -20,6 +20,13 @@ class CompletionItem {
}
}
class CompletionList {
constructor(items, isIncomplete) {
this.items = items;
this.isIncomplete = isIncomplete;
}
}
class Position {
constructor(line, character) {
this.line = line;
@@ -67,6 +74,7 @@ require.cache["vscode-stub"] = {
loaded: true,
exports: {
CompletionItem,
CompletionList,
CompletionItemKind,
Position,
Range,
@@ -123,6 +131,10 @@ const provider = makeProvider({});
const providerNoIndex = makeProvider(null);
const token = { isCancellationRequested: false };
function listItems(result) {
return Array.isArray(result) ? result : result.items;
}
test("Surfaces enum completion works with an unclosed quote", async () => {
const text =
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="G>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
@@ -468,3 +480,382 @@ test("Poid attributes offer ids from the enclosing GameObject's local scope", as
"Poid completions are labelled as local-scope ids",
);
});
test("accepting a child element after a typed < replaces the < instead of doubling it", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE"\n` +
` MinForceMagnitude="2.0"\n` +
` MaxForceMagnitude="7.0"\n` +
` DispositionIntensity="5.0"\n` +
` MinLifetime="1.0s"\n` +
` MaxLifetime="3.s"\n` +
` MinForcePitch="90d"\n` +
` MaxForcePitch="75d">\n` +
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
` <`;
const lines = text.split("\n");
const last = lines.length - 1;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
const offset = items.find((i) => i.label === "Offset");
assert.ok(createObject);
assert.ok(offset);
// The typed "<" stays in the document; the range covers only the name
// area after it (empty here), and the snippet has no leading "<", so the
// final document never contains "<<" and the filter prefix is not "<".
const ltOffset = document.offsetAt(createObject.range.start);
assert.equal(ltOffset, document.offsetAt(pos));
assert.equal(createObject.range.end.line, last);
assert.equal(createObject.range.end.character, lines[last].length);
assert.equal(offset.range.start.character, createObject.range.start.character);
// Simple-content children must be open/close pairs with a value
// placeholder, never a self-closing tag, and re-trigger value suggest.
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
assert.ok(createObject.command, "simple-content child re-triggers suggest");
const applied =
text.slice(0, document.offsetAt(createObject.range.start)) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket after accepting");
assert.match(
applied.split("\n")[last],
/<CreateObject>\$1<\/CreateObject>/,
);
});
test("no << when a closing tag follows the typed < (real file shape)", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject\n` +
` Options="IGNORE_ALL_OBJECTS"\n` +
` Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE">\n` +
` <Offset x="26.13" y="4.87" z="15.99"></Offset>\n` +
` <\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const lines = text.split("\n");
const last = 6;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject, "child element still offered after a lone <");
assert.equal(document.offsetAt(createObject.range.start), document.offsetAt(pos));
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
const applied =
text.slice(0, document.offsetAt(createObject.range.start)) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket");
const appliedLines = applied.split("\n");
assert.match(appliedLines[6], /<CreateObject>\$1<\/CreateObject>/);
});
test("no << when a partial child name was typed before the closing tag", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <Cr\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const lines = text.split("\n");
const last = 3;
const pos = new Position(last, lines[last].length);
const document = makeDocument(text);
const items = await providerNoIndex.provideCompletionItems(document, pos, token);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject);
// The "<" stays; the range covers only the typed partial name "Cr", and
// the snippet has no leading "<".
const startOffset = document.offsetAt(createObject.range.start);
assert.equal(text[startOffset], "C");
assert.equal(createObject.insertText.value, "CreateObject>$1</CreateObject>");
const applied =
text.slice(0, startOffset) +
createObject.insertText.value +
text.slice(document.offsetAt(createObject.range.end));
assert.ok(!applied.includes("<<"), "no doubled angle bracket for partial names");
assert.match(applied.split("\n")[last], /<CreateObject>\$1<\/CreateObject>/);
});
test("content completion without a typed < inserts the full tag", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` \n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const pos = new Position(3, 6);
const items = await providerNoIndex.provideCompletionItems(
makeDocument(text),
pos,
token,
);
const createObject = items.find((i) => i.label === "CreateObject");
assert.ok(createObject);
// No "<" was typed: the snippet includes the opening bracket and the
// replacement range is empty at the cursor.
assert.equal(createObject.insertText.value, "<CreateObject>$1</CreateObject>");
assert.equal(createObject.range.start.character, 6);
assert.equal(createObject.range.end.character, 6);
});
test("simple-content element offers typed asset ids as the text value", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">C") + 2);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const weapon = {
type: "WeaponTemplate",
id: "CrateWeapon_01",
file: "Weapons.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([
["GameObject", new Map([["cratedebris_01", [go]]])],
["WeaponTemplate", new Map([["crateweapon_01", [weapon]]])],
]),
assetsById: new Map([
["cratedebris_01", [go]],
["crateweapon_01", [weapon]],
]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
assert.ok(
!labels.includes("CrateWeapon_01"),
"content refs are filtered by the element's refType (GameObject)",
);
const item = items.find((i) => i.label === "CrateDebris_01");
assert.equal(item.range.start.character, line.indexOf(">C") + 1);
assert.equal(item.range.end.character, pos.character);
assert.equal(item.insertText, "CrateDebris_01");
});
test("simple-content value completion works before the closing tag is typed", async () => {
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C`;
const line = text.split("\n")[3];
const pos = new Position(3, line.length);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
assetsById: new Map([["cratedebris_01", [go]]]),
};
const items = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = items.map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
const item = items.find((i) => i.label === "CrateDebris_01");
// The unclosed element's end is the document end, so the typed "C" is
// still a real token and the range covers it.
assert.equal(item.range.start.character, line.indexOf(">C") + 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` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject></CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">") + 1);
const go = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
};
const idx = {
assets: new Map([["GameObject", new Map([["cratedebris_01", [go]]])]]),
assetsById: new Map([["cratedebris_01", [go]]]),
};
const result = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
const labels = listItems(result).map((i) => i.label);
assert.ok(labels.includes("CrateDebris_01"));
assert.ok(!labels.includes("xai:joinAction"));
assert.ok(!labels.includes("xmlns:xai"));
});
test("large asset-id lists are incomplete so narrower prefixes can re-request", async () => {
const defs = [];
for (let i = 0; i < 450; i++) {
defs.push({
type: "GameObject",
id: `C${String(i).padStart(3, "0")}`,
file: `C${i}.xml`,
line: 1,
origin: "project",
});
}
defs.push({
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
});
const byId = new Map();
const gameObjects = new Map();
const assets = new Map([["GameObject", gameObjects]]);
for (const def of defs) {
const key = def.id.toLowerCase();
byId.set(key, [def]);
gameObjects.set(key, [def]);
}
const idx = { assets, assetsById: byId };
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const pos = new Position(3, line.indexOf(">C") + 2);
const first = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
pos,
token,
);
assert.equal(Array.isArray(first), false);
assert.equal(first.isIncomplete, true, "capped list asks VS Code to recompute");
assert.equal(first.items.length, 400);
assert.ok(
!first.items.some((i) => i.label === "CrateDebris_01"),
"the target is beyond the initial 400 and must be found by a re-request",
);
// isIncomplete makes VS Code call the provider again as the prefix narrows.
const text2 = text.replace(">C<", ">Cr<");
const line2 = text2.split("\n")[3];
const second = await makeProvider(idx).provideCompletionItems(
makeDocument(text2),
new Position(3, line2.indexOf(">Cr") + 3),
token,
);
const secondItems = listItems(second);
assert.ok(
secondItems.some((i) => i.label === "CrateDebris_01"),
"narrower prefix re-request reaches the previously cut-off id",
);
});
test("current-file local overlay assets survive the global 400 cap", async () => {
const defs = [];
for (let i = 0; i < 450; i++) {
defs.push({
type: "GameObject",
id: `C${String(i).padStart(3, "0")}`,
file: `C${i}.xml`,
line: 1,
origin: "project",
});
}
const byId = new Map();
const gameObjects = new Map();
const assets = new Map([["GameObject", gameObjects]]);
for (const def of defs) {
const key = def.id.toLowerCase();
byId.set(key, [def]);
gameObjects.set(key, [def]);
}
const localGo = {
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 1,
origin: "project",
stream: "local",
};
const idx = {
assets,
assetsById: byId,
local: {
assets: new Map([
["GameObject", new Map([["cratedebris_01", [localGo]]])],
]),
assetsById: new Map([["cratedebris_01", [localGo]]]),
defines: new Map(),
},
};
const text =
`<AssetDeclaration>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>C</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const line = text.split("\n")[3];
const result = await makeProvider(idx).provideCompletionItems(
makeDocument(text),
new Position(3, line.indexOf(">C") + 2),
token,
);
assert.equal(Array.isArray(result), false);
assert.equal(result.isIncomplete, true);
assert.ok(result.items.some((i) => i.label === "CrateDebris_01"));
});
+281
View File
@@ -0,0 +1,281 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Minimal vscode shim for hover / definition / diagnostics providers.
const CompletionItemKind = {};
class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
}
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
}
class MarkdownString {
constructor(value) {
this.value = value ?? "";
}
appendMarkdown(text) {
this.value += text;
return this;
}
appendCodeblock(text) {
this.value += "\n```\n" + text + "\n```\n";
return this;
}
}
class Hover {
constructor(contents) {
this.contents = contents;
}
}
class Location {
constructor(uri, range) {
this.uri = uri;
this.range = range;
}
}
class Diagnostic {
constructor(range, message, severity) {
this.range = range;
this.message = message;
this.severity = severity;
}
}
class FakeDiagnosticCollection {
constructor() {
this.last = null;
}
set(uri, diags) {
this.last = { uri, diags };
}
delete() {}
dispose() {}
}
const Uri = {
file: (p) => ({ fsPath: p }),
};
const workspace = {
getWorkspaceFolder: (uri) => ({ uri: { fsPath: "C:/mod" } }),
};
const languages = {
createDiagnosticCollection: () => new FakeDiagnosticCollection(),
};
const require = createRequire(import.meta.url);
const Module = require("module");
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "vscode") return "vscode-stub";
return origResolve.call(this, request, ...args);
};
require.cache["vscode-stub"] = {
id: "vscode-stub",
filename: "vscode-stub",
loaded: true,
exports: {
CompletionItemKind,
Position,
Range,
MarkdownString,
Hover,
Location,
Diagnostic,
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
Uri,
workspace,
languages,
},
};
const { Ra3HoverProvider } = require("../out/features/hover.js");
const { Ra3DefinitionProvider } = require("../out/features/navigation.js");
const { Ra3Diagnostics } = require("../out/features/diagnostics.js");
const { parseXml, LineMap } = require("../out/language/xmlParser.js");
const { expandDocument } = require("../out/indexer/logicalTree.js");
const URI = "C:/mod/Data/Crates.xml";
function makeDocument(text, uri = URI) {
const lineStarts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
return {
uri: { fsPath: uri },
getText: () => text,
offsetAt: (pos) => lineStarts[pos.line] + pos.character,
positionAt: (offset) => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= offset) lo = mid;
else hi = mid - 1;
}
return new Position(lo, offset - lineStarts[lo]);
},
};
}
async function makeScope(text, idx) {
const lineMap = new LineMap(text);
const parse = parseXml(text);
const expanded = await expandDocument(URI, parse, {
resolve: () => null,
readDom: async () => null,
});
return {
uri: URI,
version: 1,
parse,
lineMap,
expanded,
lineMaps: new Map(),
overlay: {},
merged: idx,
};
}
function makeIdx(defs) {
const assets = new Map();
const assetsById = new Map();
for (const def of defs) {
const idKey = def.id.toLowerCase();
let byId = assets.get(def.type);
if (!byId) {
byId = new Map();
assets.set(def.type, byId);
}
byId.set(idKey, [def]);
assetsById.set(idKey, [def]);
}
return {
assets,
assetsById,
defines: new Map(),
projectDir: "C:/mod",
sdkDir: "C:/sdk",
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
}
const TEXT =
`<AssetDeclaration>\n` +
` <GameObject id="CrateDebris_01"/>\n` +
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
` <CreateObject>\n` +
` <CreateObject>CrateDebris_01</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
test("hover on simple-content text shows the referenced definition", async () => {
const def = {
type: "GameObject",
id: "CrateDebris_01",
file: URI,
line: 2,
origin: "project",
};
const idx = makeIdx([def]);
const scope = await makeScope(TEXT, idx);
const provider = new Ra3HoverProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
searchPaths: () => null,
});
const line = TEXT.split("\n")[4];
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
const hover = await provider.provideHover(makeDocument(TEXT), pos, {});
assert.ok(hover, "hover is returned for typed content text");
assert.match(hover.contents.value, /1 definition/);
assert.match(hover.contents.value, /GameObject/);
});
test("Ctrl+click on simple-content text jumps to the definition", async () => {
const def = {
type: "GameObject",
id: "CrateDebris_01",
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")[4];
const pos = new Position(4, line.indexOf("CrateDebris_01") + 3);
const locations = await provider.provideDefinition(makeDocument(TEXT), pos, {});
assert.ok(locations && locations.length === 1, "content definition resolves");
const defStart = TEXT.indexOf('id="CrateDebris_01"') + 'id="'.length;
const defEnd = defStart + "CrateDebris_01".length;
assert.deepEqual(
{
start: locations[0].range.start,
end: locations[0].range.end,
},
{
start: makeDocument(TEXT).positionAt(defStart),
end: makeDocument(TEXT).positionAt(defEnd),
},
);
});
test("diagnostics report unresolved typed content references only", async () => {
const text =
`<AssetDeclaration>\n` +
` <GameObject id="CrateDebris_01"/>\n` +
` <ObjectFilter>\n` +
` <IncludeThing>SomeValue</IncludeThing>\n` +
` </ObjectFilter>\n` +
` <ObjectCreationList id="OCL">\n` +
` <CreateObject>\n` +
` <CreateObject>CrateDebris_01</CreateObject>\n` +
` <CreateObject>MissingThing</CreateObject>\n` +
` </CreateObject>\n` +
` </ObjectCreationList>\n` +
`</AssetDeclaration>`;
const def = {
type: "GameObject",
id: "CrateDebris_01",
file: URI,
line: 2,
origin: "project",
};
const scope = await makeScope(text, makeIdx([def]));
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 "MissingThing"')),
"typed content refs are diagnosed",
);
assert.ok(
!messages.some((m) => m.includes("SomeValue")),
"untyped WeakReference content is not diagnosed as a global ref",
);
});
+32
View File
@@ -74,6 +74,38 @@ test("cursor after a closed quote is an attribute-name context", () => {
assert.equal(ctx.attr, null);
});
test("cursor exactly after an opening tag with a closing tag is content", () => {
const text = `<CreateObject></CreateObject>`;
const cursor = text.indexOf(">") + 1;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "CreateObject");
});
test("cursor after a typed > but before the closing tag is content too", () => {
const text = `<CreateObject>`;
const cursor = text.length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "CreateObject");
});
test("cursor after a closed child element belongs to the parent content", () => {
const text =
`<AssetDeclaration>` +
`<ObjectCreationList>` +
`<CreateObject>X</CreateObject>` +
`</ObjectCreationList>` +
`</AssetDeclaration>`;
const cursor = text.indexOf("</CreateObject>") + "</CreateObject>".length;
const doc = parseXml(text);
const ctx = analyzeContext(doc, text, cursor);
assert.equal(ctx.kind, "content");
assert.equal(ctx.element?.name, "ObjectCreationList");
});
test("splitListValuePrefix isolates the token being edited", () => {
assert.deepEqual(splitListValuePrefix("GROUND WA"), { token: "WA", start: 7 });
assert.deepEqual(splitListValuePrefix("GROUND "), { token: "", start: 7 });
+58
View File
@@ -8,6 +8,8 @@ import {
isLocalReferenceAttribute,
isReferenceAttribute,
isReferenceAttributeOfType,
isReferenceContentType,
resolveContentReferenceTargets,
resolveReferenceTargets,
resolveReferenceTargetsForType,
} from "../out/indexer/refs.js";
@@ -242,3 +244,59 @@ test("xi:include elements are outside the XSD model and unvalidated", () => {
assert.equal(model.isXsdAttributeName(a.name), true, a.name);
}
});
test("typed simple content resolves like a typed attribute reference", () => {
// <CreateObject>CrateDebris_01</CreateObject> uses GameObjectWeakRef:
// the content is a GameObject reference, not a child element.
assert.equal(isReferenceContentType("GameObjectWeakRef"), true);
const idx = {
assetsById: new Map([
[
"cratedebris_01",
[
{
type: "GameObject",
id: "CrateDebris_01",
file: "Crates.xml",
line: 2,
origin: "project",
},
{
type: "WeaponTemplate",
id: "CrateDebris_01",
file: "Weapons.xml",
line: 4,
origin: "project",
},
],
],
]),
assets: new Map(),
projectDir: ".",
sdkDir: ".",
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
stats: {},
};
const targets = resolveContentReferenceTargets(idx, "GameObjectWeakRef", "CrateDebris_01");
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "GameObject");
});
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.
assert.equal(isReferenceContentType("AssetReference"), false);
assert.equal(isReferenceContentType("Poid"), false);
assert.equal(isReferenceContentType("string"), false);
const idx = { assetsById: new Map(), assets: new Map(), defines: new Map() };
assert.equal(
resolveContentReferenceTargets(idx, "AssetReference", "Anything").length,
0,
);
assert.equal(resolveContentReferenceTargets(idx, "Poid", "Anything").length, 0);
});
+97 -1
View File
@@ -1,6 +1,11 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml, findElementAt, stripBom } from "../out/language/xmlParser.js";
import {
parseXml,
findElementAt,
stripBom,
textContentTokenAt,
} from "../out/language/xmlParser.js";
test("stripBom removes a leading UTF-8 byte-order mark", () => {
assert.equal(stripBom("\uFEFF<A/>"), "<A/>");
@@ -44,6 +49,27 @@ test("findElementAt returns innermost element", () => {
assert.equal(at.name, "C");
});
test("findElementAt treats a completed element's end as exclusive", () => {
const text = `<A><B>X</B></A>`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
const at = findElementAt(doc, b.end);
assert.equal(at?.name, "A", "cursor after </B> belongs to the parent");
const self = `<A><B/></A>`;
const selfDoc = parseXml(self);
const b2 = selfDoc.elements.find((e) => e.name === "B");
const atSelf = findElementAt(selfDoc, b2.end);
assert.equal(atSelf?.name, "A", "cursor after <B/> belongs to the parent");
});
test("findElementAt still includes EOF inside an unclosed element", () => {
const text = `<A><B>C`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
assert.equal(findElementAt(doc, text.length)?.name, "B");
});
test("tolerates partial input while typing", () => {
const text = `<AssetDeclaration>\n\t<GameObject id="TestTank" Com`;
const doc = parseXml(text);
@@ -86,3 +112,73 @@ test("reports an unterminated attribute value at EOF", () => {
assert.equal(a.attrs[0].value, "abc");
assert.equal(a.attrs[0].quoteEnd, -1);
});
test("textContentTokenAt returns the token inside element content", () => {
const text =
`<AssetDeclaration>` +
`<CreateObject> CrateDebris_01 </CreateObject>` +
`</AssetDeclaration>`;
const doc = parseXml(text);
const el = doc.elements.find((e) => e.name === "CreateObject");
const tokenStart = text.indexOf("Crate");
const cursor = tokenStart + 3;
const token = textContentTokenAt(text, el, cursor);
assert.deepEqual(token, {
value: "CrateDebris_01",
start: tokenStart,
end: tokenStart + "CrateDebris_01".length,
});
// The start tag, closing tag and whitespace-only content are not tokens.
assert.equal(textContentTokenAt(text, el, el.start + 1), null);
assert.equal(textContentTokenAt(text, el, text.indexOf("</CreateObject") + 1), null);
const empty = `<A><B/></A><C> </C>`;
const c = parseXml(empty).elements.find((e) => e.name === "C");
assert.equal(textContentTokenAt(empty, c, c.startTagEnd + 1), null);
});
test("textContentTokenAt works before the closing tag is typed", () => {
const text = `<A><B>C`;
const doc = parseXml(text);
const b = doc.elements.find((e) => e.name === "B");
assert.ok(b);
assert.equal(b.closeTagStart, -1);
const token = textContentTokenAt(text, b, text.length);
assert.deepEqual(token, {
value: "C",
start: text.indexOf("C"),
end: text.length,
});
});
test("a typed < in content followed by a closing tag does not swallow it", () => {
// In a real file the "<" the user just typed is followed by
// "</CreateObject>" on the next line. The parser must NOT treat that
// closing tag's ">" as the end of the malformed start tag (which would
// create a bogus empty-name element and break content completion).
const text =
`<AssetDeclaration>` +
`<ObjectCreationList>` +
`<CreateObject>\n\t<Offset/>\n\t<\n</CreateObject>` +
`</ObjectCreationList>` +
`</AssetDeclaration>`;
const doc = parseXml(text);
assert.ok(doc.errors.some((e) => /Unterminated start tag/.test(e.message)));
assert.ok(!doc.elements.some((e) => e.name === ""), "no bogus empty-name element");
const co = doc.elements.find((e) => e.name === "CreateObject");
assert.ok(co, "outer CreateObject still parsed");
assert.equal(co.end, text.indexOf("</CreateObject>") + "</CreateObject>".length);
});
test("a partial child name in content is recovered, not glued to the closing tag", () => {
const text = `<A><B>\n\t<Cr\n</B></A>`;
const doc = parseXml(text);
const cr = doc.elements.find((e) => e.name === "Cr");
assert.ok(cr, "partial name is recovered as an element shell");
assert.equal(cr.recoveredStartTag, true);
// The mismatched closing tag later closes the recovered shell (parser
// recovery), so the element stays a valid container for completion.
assert.equal(cr.closeTagStart, text.indexOf("</B>"));
assert.ok(cr.end > cr.startTagEnd);
const b = doc.elements.find((e) => e.name === "B");
assert.equal(b.end, text.length);
});