0.1.20
This commit is contained in:
+107
-27
@@ -17,6 +17,18 @@ class CodeLens {
|
||||
this.command = command;
|
||||
}
|
||||
}
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
this.listeners = [];
|
||||
this.event = (listener) => {
|
||||
this.listeners.push(listener);
|
||||
return { dispose: () => {} };
|
||||
};
|
||||
}
|
||||
fire() {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Module = require("module");
|
||||
@@ -32,6 +44,7 @@ require.cache["vscode-stub"] = {
|
||||
exports: {
|
||||
Range,
|
||||
CodeLens,
|
||||
EventEmitter,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,6 +85,20 @@ function makeDocument(text = TEXT) {
|
||||
}
|
||||
|
||||
function makeIndex() {
|
||||
const tankDef = {
|
||||
type: "GameObject",
|
||||
id: "TestTank",
|
||||
file: FILE,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const baseDef = {
|
||||
type: "GameObject",
|
||||
id: "BaseVehicle",
|
||||
file: FILE,
|
||||
line: 3,
|
||||
origin: "project",
|
||||
};
|
||||
const tankSite = {
|
||||
file: "C:/mod/Data/Other.xml",
|
||||
line: 7,
|
||||
@@ -88,37 +115,33 @@ function makeIndex() {
|
||||
};
|
||||
const references = new Map();
|
||||
references.set(
|
||||
assetDefKey({
|
||||
type: "GameObject",
|
||||
id: "TestTank",
|
||||
file: FILE,
|
||||
line: 2,
|
||||
}),
|
||||
assetDefKey(tankDef),
|
||||
[tankSite, secondSite],
|
||||
);
|
||||
references.set(
|
||||
assetDefKey({
|
||||
type: "GameObject",
|
||||
id: "BaseVehicle",
|
||||
file: FILE,
|
||||
line: 3,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
references.set(assetDefKey(baseDef), []);
|
||||
return {
|
||||
references,
|
||||
assets: new Map(),
|
||||
assetsById: new Map([
|
||||
["testtank", [tankDef]],
|
||||
["basevehicle", [baseDef]],
|
||||
]),
|
||||
stats: { indexedFiles: 10 },
|
||||
sdkDir: SDK,
|
||||
projectDir: PROJECT,
|
||||
};
|
||||
}
|
||||
|
||||
test("CodeLens shows counts on reference-target types only, including zero", () => {
|
||||
test("CodeLens shows counts on reference-target types only, including zero", async () => {
|
||||
const idx = makeIndex();
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: makeIndex(),
|
||||
index: idx,
|
||||
getCodeLensScope: async () => ({ merged: idx }),
|
||||
recordsSyncSurfaceFor: () => ({}),
|
||||
log: () => {},
|
||||
});
|
||||
const lenses = provider.provideCodeLenses(makeDocument(), {});
|
||||
const lenses = await provider.provideCodeLenses(makeDocument(), {});
|
||||
|
||||
assert.equal(lenses.length, 2, "no lens for auto-registered CameraSettings");
|
||||
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
|
||||
@@ -138,21 +161,69 @@ test("CodeLens shows counts on reference-target types only, including zero", ()
|
||||
assert.ok(tank.range.start.character < tank.range.end.character);
|
||||
});
|
||||
|
||||
test("CodeLens returns nothing without a workspace or index", () => {
|
||||
test("CodeLens returns nothing without a workspace or index", async () => {
|
||||
const noWorkspace = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => false,
|
||||
index: makeIndex(),
|
||||
});
|
||||
assert.deepEqual(noWorkspace.provideCodeLenses(makeDocument(), {}), []);
|
||||
assert.deepEqual(await noWorkspace.provideCodeLenses(makeDocument(), {}), []);
|
||||
|
||||
const noIndex = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: null,
|
||||
getCodeLensScope: async () => ({ merged: null }),
|
||||
recordsSyncSurfaceFor: () => ({}),
|
||||
log: () => {},
|
||||
});
|
||||
assert.deepEqual(noIndex.provideCodeLenses(makeDocument(), {}), []);
|
||||
assert.deepEqual(await noIndex.provideCodeLenses(makeDocument(), {}), []);
|
||||
});
|
||||
|
||||
test("CodeLens counts references attached to a manifest definition with the same SageXml source", () => {
|
||||
test("CodeLens hides lenses before the first global snapshot", async () => {
|
||||
const localOnly = {
|
||||
complete: false,
|
||||
stats: { indexedFiles: 0 },
|
||||
references: new Map(),
|
||||
};
|
||||
const logs = [];
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getCodeLensScope: async () => ({ merged: localOnly }),
|
||||
recordsSyncSurfaceFor: () => ({}),
|
||||
log: (m) => logs.push(m),
|
||||
});
|
||||
assert.deepEqual(await provider.provideCodeLenses(makeDocument(), {}), []);
|
||||
assert.deepEqual(await provider.provideCodeLenses(makeDocument(), {}), []);
|
||||
assert.equal(
|
||||
logs.filter((m) => m.includes("suppressed")).length,
|
||||
1,
|
||||
"suppression is logged once per document",
|
||||
);
|
||||
provider.resetSuppressionLog();
|
||||
await provider.provideCodeLenses(makeDocument(), {});
|
||||
assert.equal(
|
||||
logs.filter((m) => m.includes("suppressed")).length,
|
||||
2,
|
||||
"reset allows re-logging after a new snapshot",
|
||||
);
|
||||
});
|
||||
|
||||
test("CodeLens refresh fires onDidChangeCodeLenses", () => {
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getCodeLensScope: async () => ({ merged: null }),
|
||||
recordsSyncSurfaceFor: () => ({}),
|
||||
log: () => {},
|
||||
});
|
||||
let fired = 0;
|
||||
const subscription = provider.onDidChangeCodeLenses(() => {
|
||||
fired++;
|
||||
});
|
||||
provider.refresh();
|
||||
assert.equal(fired, 1);
|
||||
subscription.dispose();
|
||||
});
|
||||
|
||||
test("CodeLens counts references attached to a manifest definition with the same SageXml source", async () => {
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "TestTank",
|
||||
@@ -175,30 +246,39 @@ test("CodeLens counts references attached to a manifest definition with the same
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["testtank", [manifestDef]]])],
|
||||
]),
|
||||
assetsById: new Map([["testtank", [manifestDef]]]),
|
||||
stats: { indexedFiles: 10 },
|
||||
sdkDir: SDK,
|
||||
projectDir: PROJECT,
|
||||
};
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
isRa3Workspace: () => true,
|
||||
index: idx,
|
||||
getCodeLensScope: async () => ({ merged: idx }),
|
||||
recordsSyncSurfaceFor: () => ({}),
|
||||
log: () => {},
|
||||
});
|
||||
const lenses = provider.provideCodeLenses(makeDocument(), {});
|
||||
const lenses = await provider.provideCodeLenses(makeDocument(), {});
|
||||
const tank = lenses.find((l) => l.command.arguments[0].id === "TestTank");
|
||||
assert.ok(tank, "lens is shown for the SageXml-backed definition");
|
||||
assert.equal(tank.command.title, "1 reference");
|
||||
});
|
||||
|
||||
test("CodeLens schedules a targeted rebuild when the open document desyncs from the snapshot", () => {
|
||||
test("CodeLens schedules a targeted rebuild when the open document desyncs from the snapshot", async () => {
|
||||
const idx = makeIndex();
|
||||
idx.recordsHashes = new Map([[normKey(FILE), "stale-hash"]]);
|
||||
const calls = [];
|
||||
const provider = new Ra3CodeLensProvider({
|
||||
const ws = {
|
||||
isRa3Workspace: () => true,
|
||||
index: idx,
|
||||
invalidate: (p) => calls.push(["invalidate", p]),
|
||||
scheduleRebuild: (r) => calls.push(["schedule", r]),
|
||||
});
|
||||
provider.provideCodeLenses(makeDocument(), {});
|
||||
getCodeLensScope: async () => ({ merged: idx }),
|
||||
recordsSyncSurfaceFor: () => ws,
|
||||
log: () => {},
|
||||
};
|
||||
const provider = new Ra3CodeLensProvider(ws);
|
||||
await provider.provideCodeLenses(makeDocument(), {});
|
||||
assert.ok(
|
||||
calls.some(([kind]) => kind === "invalidate"),
|
||||
"the stale file is invalidated",
|
||||
|
||||
@@ -416,6 +416,64 @@ test("whitespace used to trigger the popup is consumed on newline insert", async
|
||||
assert.equal(count.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("attribute completion in the middle of a one-per-line start tag does not add a newline", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject\n` +
|
||||
` Options="IGNORE_ALL_OBJECTS"\n` +
|
||||
` C\n` +
|
||||
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
|
||||
const pos = new Position(4, 7);
|
||||
|
||||
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
|
||||
const count = items.find((i) => i.label === "Count");
|
||||
assert.ok(count);
|
||||
// The attribute is already on its own line; inserting another newline
|
||||
// would leave a blank line. Only the partial name is replaced.
|
||||
assert.equal(count.insertText.value, ' Count="1"');
|
||||
assert.equal(count.range.start.character, 0);
|
||||
assert.equal(count.range.end.character, 7);
|
||||
});
|
||||
|
||||
test("attribute completion before the first attribute on a new line does not add a newline", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject\n` +
|
||||
` C\n` +
|
||||
` Options="IGNORE_ALL_OBJECTS"\n` +
|
||||
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
|
||||
const pos = new Position(3, 7);
|
||||
|
||||
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
|
||||
const count = items.find((i) => i.label === "Count");
|
||||
assert.ok(count);
|
||||
assert.equal(count.insertText.value, ' Count="1"');
|
||||
assert.equal(count.range.start.character, 0);
|
||||
assert.equal(count.range.end.character, 7);
|
||||
});
|
||||
|
||||
test("attribute completion right after the element name still wraps in one-per-line files", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ObjectCreationList id="OCL_CrateSpawn">\n` +
|
||||
` <CreateObject \n` +
|
||||
` Options="IGNORE_ALL_OBJECTS"\n` +
|
||||
` Disposition="RANDOM_FORCE RELATIVE_ANGLE">`;
|
||||
const line3 = text.split("\n")[3];
|
||||
const pos = new Position(3, line3.length);
|
||||
|
||||
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
|
||||
const count = items.find((i) => i.label === "Count");
|
||||
assert.ok(count);
|
||||
// The new attribute would be the first one on the element-name line, so a
|
||||
// one-per-line file still wraps it onto its own line.
|
||||
assert.equal(count.insertText.value, '\nCount="1"');
|
||||
assert.equal(count.range.start.character, pos.character);
|
||||
assert.equal(count.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("scalar attributes get typed default values, suggestion attributes keep $1", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
@@ -859,3 +917,61 @@ test("current-file local overlay assets survive the global 400 cap", async () =>
|
||||
assert.equal(result.isIncomplete, true);
|
||||
assert.ok(result.items.some((i) => i.label === "CrateDebris_01"));
|
||||
});
|
||||
|
||||
test("asset-id completion shows one entry per id across local/global/manifest definitions", async () => {
|
||||
const projectDef = {
|
||||
type: "WeaponTemplate",
|
||||
id: "AlliedCommandoDesertEaglesWarhead",
|
||||
file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml",
|
||||
line: 10,
|
||||
origin: "project",
|
||||
};
|
||||
const unsavedLocalDef = {
|
||||
type: "WeaponTemplate",
|
||||
id: "AlliedCommandoDesertEaglesWarhead",
|
||||
file: "C:/mod/Data/GlobalData/Weapon/Weapon_Allied.xml",
|
||||
line: 14,
|
||||
origin: "project",
|
||||
stream: "local",
|
||||
};
|
||||
const manifestDef = {
|
||||
type: "WeaponTemplate",
|
||||
id: "AlliedCommandoDesertEaglesWarhead",
|
||||
file: "C:/sdk/builtmods/static.manifest",
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:static.xml",
|
||||
};
|
||||
const idKey = "alliedcommandodeserteagleswarhead";
|
||||
const idx = {
|
||||
assets: new Map([
|
||||
["WeaponTemplate", new Map([[idKey, [projectDef, manifestDef]]])],
|
||||
]),
|
||||
assetsById: new Map([[idKey, [projectDef, manifestDef]]]),
|
||||
local: {
|
||||
assets: new Map([["WeaponTemplate", new Map([[idKey, [unsavedLocalDef]]])]]),
|
||||
assetsById: new Map([[idKey, [unsavedLocalDef]]]),
|
||||
defines: new Map(),
|
||||
},
|
||||
};
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <ProjectileNugget WarheadTemplate="A">\n` +
|
||||
` </ProjectileNugget>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[1];
|
||||
const pos = new Position(1, line.indexOf('"A') + 2);
|
||||
|
||||
const result = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const items = listItems(result);
|
||||
const matches = items.filter((i) => i.label === "AlliedCommandoDesertEaglesWarhead");
|
||||
assert.equal(matches.length, 1, "same id from local/global/manifest is offered once");
|
||||
assert.ok(
|
||||
matches[0].documentation.value.includes("Also defined"),
|
||||
"additional definitions are listed in the item documentation",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Minimal vscode shim for hover / definition / diagnostics providers.
|
||||
const CompletionItemKind = {};
|
||||
@@ -37,7 +40,7 @@ class Hover {
|
||||
class Location {
|
||||
constructor(uri, range) {
|
||||
this.uri = uri;
|
||||
this.range = range;
|
||||
this.range = range instanceof Position ? new Range(range, range) : range;
|
||||
}
|
||||
}
|
||||
class Diagnostic {
|
||||
@@ -236,6 +239,176 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const sageFile = join(sdkDir, "SageXml", "globaldata", "weapon.xml");
|
||||
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
|
||||
mkdirSync(dirname(sageFile), { recursive: true });
|
||||
mkdirSync(dirname(modFile), { recursive: true });
|
||||
writeFileSync(
|
||||
sageFile,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
modFile,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="ModOnly"/></AssetDeclaration>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "AlliedCommandoDesertEagles",
|
||||
file: join(sdkDir, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:globaldata/weapon.xml",
|
||||
};
|
||||
const idx = makeIdx([manifestDef]);
|
||||
idx.projectDir = projectDir;
|
||||
idx.sdkDir = sdkDir;
|
||||
const text =
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
|
||||
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
|
||||
"</AssetDeclaration>";
|
||||
const scope = await makeScope(text, idx);
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: { readDom: async () => null },
|
||||
});
|
||||
|
||||
const line = text.split("\n")[1];
|
||||
const pos = new Position(
|
||||
1,
|
||||
line.indexOf("AlliedCommandoDesertEagles") + 3,
|
||||
);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.ok(locations && locations.length === 1, "manifest definition resolves");
|
||||
assert.equal(
|
||||
locations[0].uri.fsPath,
|
||||
sageFile,
|
||||
"manifest source must resolve to SageXml, not the mod shadow file",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest definition stays manifest-only when the SageXml source is missing", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-missing-"));
|
||||
try {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
|
||||
mkdirSync(dirname(modFile), { recursive: true });
|
||||
writeFileSync(
|
||||
modFile,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "AlliedCommandoDesertEagles",
|
||||
file: join(sdkDir, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:globaldata/weapon.xml",
|
||||
};
|
||||
const idx = makeIdx([manifestDef]);
|
||||
idx.projectDir = projectDir;
|
||||
idx.sdkDir = sdkDir;
|
||||
const text =
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
|
||||
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
|
||||
"</AssetDeclaration>";
|
||||
const scope = await makeScope(text, idx);
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: { readDom: async () => null },
|
||||
});
|
||||
|
||||
const line = text.split("\n")[1];
|
||||
const pos = new Position(
|
||||
1,
|
||||
line.indexOf("AlliedCommandoDesertEagles") + 3,
|
||||
);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.equal(
|
||||
locations,
|
||||
null,
|
||||
"missing vanilla source must not fall back to the mod shadow file",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest definition opens the SageXml file at the top when the id is no longer there", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-stale-"));
|
||||
try {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const sageFile = join(sdkDir, "SageXml", "globaldata", "weapon.xml");
|
||||
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
|
||||
mkdirSync(dirname(sageFile), { recursive: true });
|
||||
mkdirSync(dirname(modFile), { recursive: true });
|
||||
writeFileSync(
|
||||
sageFile,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"/>',
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
modFile,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="AlliedCommandoDesertEagles"/></AssetDeclaration>',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "AlliedCommandoDesertEagles",
|
||||
file: join(sdkDir, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:globaldata/weapon.xml",
|
||||
};
|
||||
const idx = makeIdx([manifestDef]);
|
||||
idx.projectDir = projectDir;
|
||||
idx.sdkDir = sdkDir;
|
||||
const text =
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n' +
|
||||
' <GameObject id="MyUnit" inheritFrom="AlliedCommandoDesertEagles"/>\n' +
|
||||
"</AssetDeclaration>";
|
||||
const scope = await makeScope(text, idx);
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: { readDom: async () => null },
|
||||
});
|
||||
|
||||
const line = text.split("\n")[1];
|
||||
const pos = new Position(
|
||||
1,
|
||||
line.indexOf("AlliedCommandoDesertEagles") + 3,
|
||||
);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.ok(locations && locations.length === 1);
|
||||
assert.equal(locations[0].uri.fsPath, sageFile);
|
||||
assert.equal(locations[0].range.start.line, 0);
|
||||
assert.equal(locations[0].range.start.character, 0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("diagnostics report unresolved typed content references only", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
|
||||
@@ -137,3 +137,56 @@ test("diskCacheKey differs when the identity changes", () => {
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a, diskCacheKey(identity));
|
||||
});
|
||||
|
||||
test("load returns records without stat validation", async (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const file = join(tmp, "a.xml");
|
||||
fs.writeFileSync(file, "0123456789");
|
||||
const filePath = join(tmp, "index-records.json.gz");
|
||||
const cache = new DiskRecordsCache(filePath, identity);
|
||||
await cache.save([
|
||||
[
|
||||
file.toLowerCase(),
|
||||
{ stat: stampOf(file), records: sampleRecords, kind: "full" },
|
||||
],
|
||||
]);
|
||||
|
||||
const { records, stats } = await cache.load();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(stats.fileExists, true);
|
||||
assert.equal(stats.keyMatched, true);
|
||||
assert.equal(stats.loaded, 1);
|
||||
assert.equal(stats.validated, 0);
|
||||
assert.equal(stats.dropped, 0);
|
||||
assert.ok(stats.loadMs >= 0);
|
||||
});
|
||||
|
||||
test("validate reports changed/missing entries and keeps valid ones", async (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const a = join(tmp, "a.xml");
|
||||
const b = join(tmp, "b.xml");
|
||||
fs.writeFileSync(a, "0123456789");
|
||||
fs.writeFileSync(b, "0123456789");
|
||||
const filePath = join(tmp, "index-records.json.gz");
|
||||
const cache = new DiskRecordsCache(filePath, identity);
|
||||
await cache.save([
|
||||
[a.toLowerCase(), { stat: stampOf(a), records: sampleRecords, kind: "full" }],
|
||||
[b.toLowerCase(), { stat: stampOf(b), records: sampleRecords, kind: "full" }],
|
||||
]);
|
||||
|
||||
const { records } = await cache.load();
|
||||
const past = new Date(Date.now() - 60000);
|
||||
fs.utimesSync(b, past, past);
|
||||
const progress = [];
|
||||
const { stats, kept, invalidKeys } = await cache.validate(records, (done, total) => {
|
||||
progress.push([done, total]);
|
||||
});
|
||||
assert.equal(stats.validated, 1);
|
||||
assert.equal(stats.dropped, 1);
|
||||
assert.equal(kept.length, 1);
|
||||
assert.equal(kept[0].key, a.toLowerCase());
|
||||
assert.deepEqual(invalidKeys, [b.toLowerCase()]);
|
||||
assert.ok(stats.validateMs >= 0);
|
||||
assert.deepEqual(progress[progress.length - 1], [1, 2]);
|
||||
assert.ok(progress.every(([done], i) => i === 0 || done >= progress[i - 1][0]));
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" />
|
||||
@@ -2,8 +2,11 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
buildSearchPaths,
|
||||
buildVanillaSearchPaths,
|
||||
resolveSource,
|
||||
manifestPathForReference,
|
||||
} from "../out/indexer/includeResolver.js";
|
||||
@@ -81,6 +84,94 @@ test("DATA:Static.xml prefers the SDK root over SageXml", () => {
|
||||
assert.equal(r.path, join(sdk, "Static.xml"));
|
||||
});
|
||||
|
||||
test("vanilla search paths stay inside the SDK", () => {
|
||||
const vanilla = buildVanillaSearchPaths(sdk);
|
||||
assert.deepEqual(vanilla.DATA, [sdk, join(sdk, "SageXml")]);
|
||||
assert.deepEqual(vanilla.ART, [sdk, join(sdk, "Art")]);
|
||||
assert.deepEqual(vanilla.AUDIO, [sdk, join(sdk, "Audio")]);
|
||||
});
|
||||
|
||||
test("empty SDK path produces project-only search paths", () => {
|
||||
const modParent = dirname(project);
|
||||
const granParent = dirname(modParent);
|
||||
const paths = buildSearchPaths("", project);
|
||||
assert.deepEqual(paths.DATA, [
|
||||
granParent,
|
||||
join(project, "Data"),
|
||||
modParent,
|
||||
]);
|
||||
assert.deepEqual(paths.ART, [
|
||||
granParent,
|
||||
join(project, "Art1"),
|
||||
join(project, "Art"),
|
||||
modParent,
|
||||
]);
|
||||
assert.deepEqual(paths.AUDIO, [
|
||||
granParent,
|
||||
join(project, "Audio1"),
|
||||
join(project, "Audio"),
|
||||
modParent,
|
||||
]);
|
||||
const r = resolveSource("DATA:static.xml", null, paths);
|
||||
assert.equal(r.path, null, "DATA: include never falls back to the cwd");
|
||||
|
||||
const vanilla = buildVanillaSearchPaths("");
|
||||
assert.deepEqual(vanilla.DATA, []);
|
||||
assert.deepEqual(vanilla.ART, []);
|
||||
assert.deepEqual(vanilla.AUDIO, []);
|
||||
});
|
||||
|
||||
test("manifest sources resolve with vanilla-only paths (mod shadow ignored)", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-vanilla-"));
|
||||
try {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const rel = "globaldata/weapon.xml";
|
||||
const sageFile = join(sdkDir, "SageXml", rel);
|
||||
const modFile = join(projectDir, "Data", rel);
|
||||
mkdirSync(dirname(sageFile), { recursive: true });
|
||||
mkdirSync(dirname(modFile), { recursive: true });
|
||||
writeFileSync(sageFile, "<AssetDeclaration/>", "utf8");
|
||||
writeFileSync(modFile, "<AssetDeclaration/>", "utf8");
|
||||
|
||||
const normal = resolveSource(
|
||||
"DATA:globaldata/weapon.xml",
|
||||
null,
|
||||
buildSearchPaths(sdkDir, projectDir),
|
||||
);
|
||||
const vanilla = resolveSource(
|
||||
"DATA:globaldata/weapon.xml",
|
||||
null,
|
||||
buildVanillaSearchPaths(sdkDir),
|
||||
);
|
||||
|
||||
assert.equal(normal.path, modFile, "normal BAB order picks the mod file");
|
||||
assert.equal(vanilla.path, sageFile, "manifest source stays on SageXml");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("missing vanilla source returns null even when the project shadows the path", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-vanilla-missing-"));
|
||||
try {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const modFile = join(projectDir, "Data", "globaldata", "weapon.xml");
|
||||
mkdirSync(dirname(modFile), { recursive: true });
|
||||
writeFileSync(modFile, "<AssetDeclaration/>", "utf8");
|
||||
|
||||
const vanilla = resolveSource(
|
||||
"DATA:globaldata/weapon.xml",
|
||||
null,
|
||||
buildVanillaSearchPaths(sdkDir),
|
||||
);
|
||||
assert.equal(vanilla.path, null);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest mapping strips the prefix", () => {
|
||||
const dirs = [join(sdk, "builtmods")];
|
||||
assert.equal(manifestPathForReference("DATA:static.xml", dirs), join(dirs[0], "static.manifest"));
|
||||
|
||||
@@ -315,6 +315,47 @@ test("trusted rebuilds skip unchanged files; invalidation forces re-reads", asyn
|
||||
assert.equal(forced.stats.shallowCacheHits, 2);
|
||||
});
|
||||
|
||||
test("unvalidated shallow entries are deferred in phase A and stat-verified before phase B", async () => {
|
||||
const documentCache = new DocumentCache();
|
||||
const recordsCache = new IndexRecordsCache();
|
||||
const resolveCache = new IncludeResolveCache();
|
||||
const opts = () => ({
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
documentCache,
|
||||
recordsCache,
|
||||
resolveCache,
|
||||
trustUnchanged: true,
|
||||
});
|
||||
|
||||
const first = await new ModIndexer(opts()).build();
|
||||
// Simulate the workspace pre-seeding a disk cache: shallow records are
|
||||
// present but not stat-validated yet.
|
||||
for (const [, entry] of recordsCache.entries()) {
|
||||
if (entry.kind === "shallow") entry.validated = false;
|
||||
}
|
||||
|
||||
let phaseA = null;
|
||||
const second = await new ModIndexer(opts()).build((p) => {
|
||||
phaseA = p;
|
||||
});
|
||||
assert.equal(phaseA.stats.deferredArtFiles, 2, "art files registered, not consumed, in phase A");
|
||||
assert.equal(
|
||||
phaseA.assetsById.has("tank_skn"),
|
||||
false,
|
||||
"art assets are deferred until phase B",
|
||||
);
|
||||
assert.equal(second.stats.shallowScannedFiles, 0, "validated records are not re-scanned");
|
||||
assert.ok(
|
||||
second.assetsById.get("tank_skn")?.some((d) => d.type === "W3DContainer"),
|
||||
"art asset present after phase B",
|
||||
);
|
||||
});
|
||||
|
||||
test("index stats include candidate/walk phase timings", async () => {
|
||||
const idx = await buildIndex();
|
||||
assert.equal(typeof idx.stats.candidatesMs, "number");
|
||||
@@ -360,3 +401,43 @@ test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
|
||||
assert.ok(def, "BOM-prefixed w3x asset indexed");
|
||||
assert.equal(def.line, 3, "id line is correct despite the BOM");
|
||||
});
|
||||
|
||||
test("indexes the project without an SDK path (project-only mode)", async () => {
|
||||
const idx = await new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: "",
|
||||
builtmodsDirs: [],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
}).build();
|
||||
|
||||
assert.ok(idx.complete, "build completes without an SDK");
|
||||
assert.ok(idx.assetsById.has("testtank"), "project assets are still indexed");
|
||||
assert.equal(
|
||||
idx.diagnostics.some(
|
||||
(d) => d.code === "include-not-found" && /DATA:/.test(d.message),
|
||||
),
|
||||
false,
|
||||
"SDK-only include misses are suppressed in project-only mode",
|
||||
);
|
||||
assert.ok(
|
||||
idx.diagnostics.some((d) => d.code === "sdk-not-configured"),
|
||||
"one summary SDK diagnostic is reported",
|
||||
);
|
||||
});
|
||||
|
||||
test("missing SDK path does not abort the build", async () => {
|
||||
const missing = join(os.tmpdir(), "ra3modxml-no-such-sdk");
|
||||
const idx = await new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: missing,
|
||||
builtmodsDirs: [],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
}).build();
|
||||
|
||||
assert.ok(idx.complete);
|
||||
assert.ok(idx.assetsById.has("testtank"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
findProjectRootUpward,
|
||||
findProjectRootForFile,
|
||||
discoverProjects,
|
||||
isProjectRoot,
|
||||
} = require("../out/projectRoot.js");
|
||||
|
||||
let fixtureRoot;
|
||||
let counter = 0;
|
||||
|
||||
function scratch(rel = "") {
|
||||
if (!fixtureRoot) {
|
||||
fixtureRoot = mkdtempSync(join(tmpdir(), "ra3-projectroot-"));
|
||||
}
|
||||
const dir = join(fixtureRoot, String(counter++), rel);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function write(dir, rel, content = "") {
|
||||
const file = join(dir, rel);
|
||||
mkdirSync(join(file, ".."), { recursive: true });
|
||||
writeFileSync(file, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (fixtureRoot) rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("upward discovery: Data folder, subfolders and additionalmaps", () => {
|
||||
const root = scratch();
|
||||
write(root, "Data/Mod.xml", "<AssetDeclaration/>");
|
||||
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
|
||||
assert.equal(
|
||||
findProjectRootUpward(join(root, "Data", "GlobalData", "Units")),
|
||||
resolve(root),
|
||||
);
|
||||
assert.equal(
|
||||
findProjectRootUpward(join(root, "Data", "additionalmaps", "nested")),
|
||||
resolve(root),
|
||||
);
|
||||
});
|
||||
|
||||
test("upward discovery: mapmetadata-only mod", () => {
|
||||
const root = scratch();
|
||||
write(root, "Data/additionalmaps/mapmetadata_Global.xml", "<MapMetadata/>");
|
||||
assert.equal(
|
||||
findProjectRootUpward(join(root, "Data", "additionalmaps")),
|
||||
resolve(root),
|
||||
);
|
||||
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
|
||||
assert.equal(isProjectRoot(root), true);
|
||||
});
|
||||
|
||||
test("upward discovery: case-insensitive Data and Mod.xml", () => {
|
||||
const root = scratch();
|
||||
write(root, "data/mod.xml", "<AssetDeclaration/>");
|
||||
assert.equal(findProjectRootUpward(join(root, "Data")), resolve(root));
|
||||
assert.equal(findProjectRootUpward(root), resolve(root));
|
||||
});
|
||||
|
||||
test("upward discovery: babproj markers", () => {
|
||||
const root = scratch();
|
||||
write(root, "mod.babproj", "");
|
||||
assert.equal(findProjectRootUpward(root), resolve(root));
|
||||
|
||||
const root2 = scratch();
|
||||
write(root2, "SomeProject.babproj", "");
|
||||
assert.equal(findProjectRootUpward(root2), resolve(root2));
|
||||
});
|
||||
|
||||
test("upward discovery: no marker returns null", () => {
|
||||
const root = scratch();
|
||||
write(root, "random/file.txt", "x");
|
||||
assert.equal(findProjectRootUpward(root), null);
|
||||
assert.equal(findProjectRootUpward(join(root, "random")), null);
|
||||
});
|
||||
|
||||
test("upward discovery: max depth respected", () => {
|
||||
const root = scratch();
|
||||
write(root, "Data/Mod.xml", "<AssetDeclaration/>");
|
||||
let deep = root;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
deep = join(deep, `level${i}`);
|
||||
mkdirSync(deep);
|
||||
}
|
||||
assert.equal(findProjectRootUpward(deep, 12), null);
|
||||
assert.equal(findProjectRootUpward(deep, 20), resolve(root));
|
||||
});
|
||||
|
||||
test("upward discovery from a single file", () => {
|
||||
const root = scratch();
|
||||
write(root, "Data/additionalmaps/mapmetadata_Maps.xml", "<MapMetadata/>");
|
||||
const file = write(root, "Data/Units/Unit.xml", "<AssetDeclaration/>");
|
||||
assert.equal(findProjectRootForFile(file), resolve(root));
|
||||
});
|
||||
|
||||
test("discoverProjects: sibling mods in a container", () => {
|
||||
const container = scratch();
|
||||
write(container, "ModA/Data/Mod.xml", "<AssetDeclaration/>");
|
||||
write(
|
||||
container,
|
||||
"ModB/Data/additionalmaps/mapmetadata_B.xml",
|
||||
"<MapMetadata/>",
|
||||
);
|
||||
const found = discoverProjects(container).map((p) => resolve(p));
|
||||
assert.equal(found.length, 2);
|
||||
assert.ok(found.includes(resolve(join(container, "ModA"))));
|
||||
assert.ok(found.includes(resolve(join(container, "ModB"))));
|
||||
});
|
||||
|
||||
test("discoverProjects: SDK-style deep layout (mods/mods/corona)", () => {
|
||||
const container = scratch();
|
||||
write(
|
||||
container,
|
||||
"mods/mods/corona/Data/Mod.xml",
|
||||
"<AssetDeclaration/>",
|
||||
);
|
||||
const found = discoverProjects(container).map((p) => resolve(p));
|
||||
assert.deepEqual(found, [resolve(join(container, "mods", "mods", "corona"))]);
|
||||
});
|
||||
|
||||
test("discoverProjects: skips known non-mod directories", () => {
|
||||
const container = scratch();
|
||||
write(
|
||||
container,
|
||||
"node_modules/FakeMod/Data/Mod.xml",
|
||||
"<AssetDeclaration/>",
|
||||
);
|
||||
write(container, ".git/Data/Mod.xml", "<AssetDeclaration/>");
|
||||
assert.deepEqual(discoverProjects(container), []);
|
||||
});
|
||||
|
||||
test("discoverProjects: de-duplicates and stops at a root", () => {
|
||||
const container = scratch();
|
||||
write(container, "ModA/Data/Mod.xml", "<AssetDeclaration/>");
|
||||
write(container, "ModA/Inner/Data/Mod.xml", "<AssetDeclaration/>");
|
||||
const first = discoverProjects(container);
|
||||
const second = discoverProjects(container);
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(first.length, 1);
|
||||
assert.equal(resolve(first[0]), resolve(join(container, "ModA")));
|
||||
});
|
||||
|
||||
test("nested roots: nearest ancestor wins upward", () => {
|
||||
const outer = scratch();
|
||||
write(outer, "Data/Mod.xml", "<AssetDeclaration/>");
|
||||
const inner = join(outer, "Inner");
|
||||
write(inner, "Data/Mod.xml", "<AssetDeclaration/>");
|
||||
const file = write(inner, "Data/Units/Unit.xml", "<AssetDeclaration/>");
|
||||
assert.equal(findProjectRootForFile(file), resolve(inner));
|
||||
});
|
||||
@@ -2,7 +2,13 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||
@@ -152,47 +158,61 @@ test("records extracted from XML resolve through the reference index", () => {
|
||||
});
|
||||
|
||||
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
|
||||
const sourceFile = join(project, "Data", "Includes", "Units.xml");
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: join(sdk, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:Includes/Units.xml",
|
||||
};
|
||||
const site = {
|
||||
file: "C:/mod/ref.xml",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 14,
|
||||
kind: "attr",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
|
||||
assetsById: new Map([["tank", [manifestDef]]]),
|
||||
references: new Map([[assetDefKey(manifestDef), [site]]]),
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
};
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
|
||||
try {
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const projectDir = join(tmp, "project");
|
||||
const sourceFile = join(sdkDir, "SageXml", "Includes", "Units.xml");
|
||||
const shadowFile = join(projectDir, "Data", "Includes", "Units.xml");
|
||||
mkdirSync(dirname(sourceFile), { recursive: true });
|
||||
mkdirSync(dirname(shadowFile), { recursive: true });
|
||||
writeFileSync(sourceFile, "<AssetDeclaration/>", "utf8");
|
||||
writeFileSync(shadowFile, "<AssetDeclaration/>", "utf8");
|
||||
|
||||
const sites = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: sourceFile,
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(sites.length, 1);
|
||||
assert.equal(sites[0].file, "C:/mod/ref.xml");
|
||||
const manifestDef = {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: join(sdkDir, "builtmods", "static.manifest"),
|
||||
line: 0,
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:Includes/Units.xml",
|
||||
};
|
||||
const site = {
|
||||
file: "C:/mod/ref.xml",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 14,
|
||||
kind: "attr",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["GameObject", new Map([["tank", [manifestDef]]])]]),
|
||||
assetsById: new Map([["tank", [manifestDef]]]),
|
||||
references: new Map([[assetDefKey(manifestDef), [site]]]),
|
||||
projectDir,
|
||||
sdkDir,
|
||||
};
|
||||
|
||||
// A different file does not inherit the manifest definition's sites.
|
||||
const other = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: "C:/mod/elsewhere.xml",
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(other.length, 0);
|
||||
const sites = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: sourceFile,
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(sites.length, 1);
|
||||
assert.equal(sites[0].file, "C:/mod/ref.xml");
|
||||
|
||||
// The mod file shadowing the same DATA: path must NOT inherit the
|
||||
// manifest definition's sites; manifestSource maps to SageXml only.
|
||||
const other = referenceSitesForDefinition(idx, {
|
||||
type: "GameObject",
|
||||
id: "Tank",
|
||||
file: shadowFile,
|
||||
line: 4,
|
||||
});
|
||||
assert.equal(other.length, 0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the minimod indexer publishes a semantic reverse reference index", async () => {
|
||||
|
||||
@@ -122,15 +122,25 @@ function makeScope() {
|
||||
function makeWs(scope) {
|
||||
const parse = parseXml(TEXT);
|
||||
const lineMap = new LineMap(TEXT);
|
||||
const indexer = {
|
||||
readDom: async (path) =>
|
||||
path === FILE
|
||||
? { file: { path: FILE }, parse, lineMap, records: null }
|
||||
: null,
|
||||
};
|
||||
return {
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
indexer: {
|
||||
readDom: async (path) =>
|
||||
path === FILE
|
||||
? { file: { path: FILE }, parse, lineMap, records: null }
|
||||
: null,
|
||||
},
|
||||
indexer,
|
||||
indexerForFile: () => indexer,
|
||||
activeIndexer: () => indexer,
|
||||
recordsSyncSurfaceFor: () => ({
|
||||
get index() {
|
||||
return scope.merged;
|
||||
},
|
||||
invalidate: () => {},
|
||||
scheduleRebuild: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
normalizeSdkPath,
|
||||
parseRegistryInstallLocation,
|
||||
validateSdkPath,
|
||||
} from "../out/sdk.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function makeSdk(extra = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-sdk-"));
|
||||
const rel = (p) => join(dir, ...p.split("/"));
|
||||
mkdirSync(rel("Schemas/xsd"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(rel("Schemas/xsd"), "CnC3Types.xsd"),
|
||||
"<xs:schema/>",
|
||||
"utf8",
|
||||
);
|
||||
for (const d of extra.dirs ?? []) mkdirSync(rel(d), { recursive: true });
|
||||
for (const f of extra.files ?? []) {
|
||||
mkdirSync(dirname(rel(f)), { recursive: true });
|
||||
writeFileSync(rel(f), "x", "utf8");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function withTemp(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-sdk-case-"));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("normalizeSdkPath trims quotes and resolves to an absolute path", () => {
|
||||
const raw = ` "${join(root, "test", "fixtures", "fakesdk")}" `;
|
||||
assert.equal(normalizeSdkPath(raw), resolve(join(root, "test", "fixtures", "fakesdk")));
|
||||
assert.equal(normalizeSdkPath(" "), "");
|
||||
assert.equal(normalizeSdkPath(""), "");
|
||||
});
|
||||
|
||||
test("validateSdkPath: empty value is missing", () => {
|
||||
const v = validateSdkPath("");
|
||||
assert.equal(v.status, "missing");
|
||||
assert.equal(v.path, "");
|
||||
});
|
||||
|
||||
test("validateSdkPath: nonexistent path is missing", () => {
|
||||
const v = validateSdkPath(join(tmpdir(), "ra3-no-such-sdk"));
|
||||
assert.equal(v.status, "missing");
|
||||
assert.ok(v.path);
|
||||
});
|
||||
|
||||
test("validateSdkPath: directory without the SDK marker is not an SDK", () => {
|
||||
withTemp((dir) => {
|
||||
const v = validateSdkPath(dir);
|
||||
assert.equal(v.status, "not-sdk");
|
||||
assert.deepEqual(v.missing, ["Schemas/xsd/CnC3Types.xsd"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("validateSdkPath: partial lists the missing functional items", () => {
|
||||
const dir = makeSdk({
|
||||
dirs: ["builtmods"],
|
||||
files: ["Static.xml"],
|
||||
});
|
||||
try {
|
||||
const v = validateSdkPath(dir);
|
||||
assert.equal(v.status, "partial");
|
||||
assert.deepEqual(v.missing, [
|
||||
"SageXml",
|
||||
"Mods",
|
||||
"Global.xml",
|
||||
"Audio.xml",
|
||||
]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateSdkPath: complete SDK is ok", () => {
|
||||
const dir = makeSdk({
|
||||
dirs: ["builtmods", "SageXml", "Mods"],
|
||||
files: ["Static.xml", "Global.xml", "Audio.xml"],
|
||||
});
|
||||
try {
|
||||
const v = validateSdkPath(dir);
|
||||
assert.equal(v.status, "ok");
|
||||
assert.deepEqual(v.missing, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateSdkPath: marker matching is case-insensitive on Windows", (t) => {
|
||||
if (process.platform !== "win32") return t.skip("case-insensitive fs is Windows-only");
|
||||
withTemp((dir) => {
|
||||
mkdirSync(join(dir, "schemas", "xsd"), { recursive: true });
|
||||
writeFileSync(join(dir, "schemas", "xsd", "cnc3types.xsd"), "x", "utf8");
|
||||
const v = validateSdkPath(dir);
|
||||
assert.notEqual(v.status, "not-sdk", "lower-case marker still identifies the SDK");
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRegistryInstallLocation extracts the value from reg.exe output", () => {
|
||||
const out = [
|
||||
"",
|
||||
"HKEY_LOCAL_MACHINE\\Software\\Wow6432Node\\...",
|
||||
" InstallLocation REG_SZ C:\\Apps\\RA3-MODSDK-X",
|
||||
"",
|
||||
].join("\r\n");
|
||||
assert.equal(parseRegistryInstallLocation(out), "C:\\Apps\\RA3-MODSDK-X");
|
||||
assert.equal(parseRegistryInstallLocation("no such key"), null);
|
||||
assert.equal(
|
||||
parseRegistryInstallLocation(" DisplayName REG_SZ SDK"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
// ── Minimal vscode shim for ModWorkspace (multi-project behavior) ──────
|
||||
const stubState = {
|
||||
workspaceFolders: [],
|
||||
textDocuments: [],
|
||||
activeEditor: null,
|
||||
config: {
|
||||
sdkPath: "",
|
||||
indexSageXml: true,
|
||||
reportUnresolvedReferences: "warning",
|
||||
diagnoseUnknownElements: true,
|
||||
definitionMode: "all",
|
||||
additionalDataSearchPaths: [],
|
||||
},
|
||||
};
|
||||
|
||||
class RelativePattern {
|
||||
constructor(base, pattern) {
|
||||
this.base = base;
|
||||
this.pattern = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
class OutputChannel {
|
||||
appendLine() {}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
class StatusBarItem {
|
||||
constructor() {
|
||||
this.name = "";
|
||||
this.command = "";
|
||||
this.text = "";
|
||||
this.tooltip = "";
|
||||
}
|
||||
show() {
|
||||
this.visible = true;
|
||||
}
|
||||
hide() {
|
||||
this.visible = false;
|
||||
}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
function makeWatcher() {
|
||||
return {
|
||||
onDidCreate: () => ({ dispose() {} }),
|
||||
onDidChange: () => ({ dispose() {} }),
|
||||
onDidDelete: () => ({ dispose() {} }),
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
RelativePattern,
|
||||
StatusBarAlignment: { Left: 1 },
|
||||
workspace: {
|
||||
getConfiguration: () => ({
|
||||
get: (key, def) => stubState.config[key] ?? def,
|
||||
}),
|
||||
get workspaceFolders() {
|
||||
return stubState.workspaceFolders;
|
||||
},
|
||||
get textDocuments() {
|
||||
return stubState.textDocuments;
|
||||
},
|
||||
createFileSystemWatcher: () => makeWatcher(),
|
||||
onDidCloseTextDocument: () => ({ dispose() {} }),
|
||||
},
|
||||
window: {
|
||||
createOutputChannel: () => new OutputChannel(),
|
||||
createStatusBarItem: () => new StatusBarItem(),
|
||||
get activeTextEditor() {
|
||||
return stubState.activeEditor;
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
executeCommand: async () => undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { ModWorkspace } = require("../out/workspace.js");
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────
|
||||
const FAKE_SDK = fileURLToPath(new URL("./fixtures/fakesdk", import.meta.url));
|
||||
let tmpRoot;
|
||||
let modA;
|
||||
let modB;
|
||||
let container;
|
||||
let storageDir;
|
||||
const MOD_A_TEXT = "<AssetDeclaration><GameObject id=\"UnitA\"/></AssetDeclaration>";
|
||||
const MOD_B_TEXT = "<AssetDeclaration><GameObject id=\"UnitB\"/></AssetDeclaration>";
|
||||
|
||||
test.before(() => {
|
||||
stubState.config.sdkPath = FAKE_SDK;
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "ra3-multimod-"));
|
||||
modA = join(tmpRoot, "container", "ModA");
|
||||
modB = join(tmpRoot, "container", "ModB");
|
||||
container = join(tmpRoot, "container");
|
||||
storageDir = join(tmpRoot, "storage");
|
||||
mkdirSync(join(modA, "Data"), { recursive: true });
|
||||
mkdirSync(join(modB, "Data"), { recursive: true });
|
||||
writeFileSync(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
|
||||
writeFileSync(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeDoc(fsPath, text = "<AssetDeclaration/>") {
|
||||
return {
|
||||
uri: {
|
||||
fsPath,
|
||||
scheme: "file",
|
||||
toString: () => `file://${fsPath}`,
|
||||
},
|
||||
languageId: "xml",
|
||||
isDirty: false,
|
||||
version: 1,
|
||||
getText: () => text,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkspace(folders) {
|
||||
stubState.workspaceFolders = folders;
|
||||
stubState.textDocuments = [];
|
||||
stubState.activeEditor = null;
|
||||
const context = {
|
||||
storageUri: { fsPath: storageDir },
|
||||
globalStorageUri: null,
|
||||
subscriptions: [],
|
||||
};
|
||||
return new ModWorkspace(context);
|
||||
}
|
||||
|
||||
async function waitForIndex(ws, doc) {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const idx = await ws.getIndex(doc);
|
||||
if (idx?.complete && idx.stats.assetCount > 0) return idx;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error(`timed out waiting for index of ${doc.uri.fsPath}`);
|
||||
}
|
||||
|
||||
function stateForRoot(ws, root) {
|
||||
const wanted = resolve(root).toLowerCase();
|
||||
return [...ws.states.values()].find(
|
||||
(s) => resolve(s.root).toLowerCase() === wanted,
|
||||
);
|
||||
}
|
||||
|
||||
test("single project folder is indexed immediately on initialize", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: modA } }]);
|
||||
await ws.initialize();
|
||||
assert.equal(ws.getProjectRoots().length, 1);
|
||||
const idx = ws.activeIndex();
|
||||
assert.ok(idx);
|
||||
assert.equal(resolve(idx.stats.projectDir), resolve(modA));
|
||||
assert.ok(idx.assetsById.has("unita"));
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
test("container folder discovers two projects and indexes lazily", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
|
||||
await ws.initialize();
|
||||
|
||||
assert.equal(ws.getProjectRoots().length, 2);
|
||||
assert.equal(ws.activeIndex(), null);
|
||||
|
||||
const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
|
||||
const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
|
||||
assert.equal(ws.getProjectRootFor(docA), resolve(modA));
|
||||
assert.equal(ws.getProjectRootFor(docB), resolve(modB));
|
||||
assert.ok(
|
||||
ws.searchPaths(docA).DATA.some((d) => resolve(d) === resolve(join(modA, "Data"))),
|
||||
);
|
||||
|
||||
// Opening ModA's document builds only ModA.
|
||||
ws.onDocumentOpened(docA);
|
||||
const idxA = await waitForIndex(ws, docA);
|
||||
assert.equal(resolve(idxA.stats.projectDir), resolve(modA));
|
||||
assert.ok(idxA.assetsById.has("unita"));
|
||||
|
||||
const stateB = stateForRoot(ws, modB);
|
||||
assert.ok(stateB);
|
||||
assert.equal(stateB.index, null);
|
||||
assert.equal(stateB.buildCount, 0);
|
||||
|
||||
// Opening ModB's document builds ModB.
|
||||
ws.onDocumentOpened(docB);
|
||||
const idxB = await waitForIndex(ws, docB);
|
||||
assert.equal(resolve(idxB.stats.projectDir), resolve(modB));
|
||||
assert.ok(idxB.assetsById.has("unitb"));
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
test("with multiple projects the active editor's project builds on initialize", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
|
||||
stubState.activeEditor = {
|
||||
document: makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT),
|
||||
};
|
||||
await ws.initialize();
|
||||
const idx = ws.activeIndex();
|
||||
assert.ok(idx);
|
||||
assert.equal(resolve(idx.stats.projectDir), resolve(modB));
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
test("workspace folder changes add and remove projects", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
|
||||
await ws.initialize();
|
||||
assert.equal(ws.getProjectRoots().length, 2);
|
||||
|
||||
stubState.workspaceFolders = [{ uri: { fsPath: modA } }];
|
||||
ws.onWorkspaceFoldersChanged();
|
||||
assert.equal(ws.getProjectRoots().length, 1);
|
||||
assert.equal(resolve(ws.getProjectRoots()[0]), resolve(modA));
|
||||
|
||||
stubState.workspaceFolders = [{ uri: { fsPath: container } }];
|
||||
ws.onWorkspaceFoldersChanged();
|
||||
assert.equal(ws.getProjectRoots().length, 2);
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
test("an unrelated active XML document falls back without recursion", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
|
||||
const outside = join(tmpRoot, "outside.xml");
|
||||
writeFileSync(outside, "<AssetDeclaration/>");
|
||||
stubState.activeEditor = { document: makeDoc(outside) };
|
||||
await ws.initialize();
|
||||
// No project contains the active document, so nothing builds eagerly and
|
||||
// activeIndex resolves to the first project (or null) without recursing.
|
||||
assert.equal(ws.getProjectRoots().length, 2);
|
||||
const idx = ws.activeIndex();
|
||||
assert.equal(idx, null);
|
||||
ws.dispose();
|
||||
});
|
||||
|
||||
test("rebuilds for both projects complete through the serialized queue", async () => {
|
||||
const ws = makeWorkspace([{ uri: { fsPath: container } }]);
|
||||
await ws.initialize();
|
||||
const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT);
|
||||
const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT);
|
||||
|
||||
const p1 = ws.rebuild(false, "test-a", docA);
|
||||
const p2 = ws.rebuild(false, "test-b", docB);
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
const idxA = await waitForIndex(ws, docA);
|
||||
const idxB = await waitForIndex(ws, docB);
|
||||
assert.equal(resolve(idxA.stats.projectDir), resolve(modA));
|
||||
assert.equal(resolve(idxB.stats.projectDir), resolve(modB));
|
||||
assert.ok(idxA.assetsById.has("unita"));
|
||||
assert.ok(idxB.assetsById.has("unitb"));
|
||||
ws.dispose();
|
||||
});
|
||||
Reference in New Issue
Block a user