This commit is contained in:
2026-08-11 02:20:06 +02:00
parent 36eaaafa01
commit dbc2c99d8d
45 changed files with 3904 additions and 661 deletions
+107 -27
View File
@@ -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",
+116
View File
@@ -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",
);
});
+174 -1
View File
@@ -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` +
+53
View File
@@ -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]));
});
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" />
+91
View File
@@ -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"));
+81
View File
@@ -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"));
});
+161
View File
@@ -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));
});
+60 -40
View File
@@ -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 () => {
+16 -6
View File
@@ -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: () => {},
}),
};
}
+124
View File
@@ -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,
);
});
+281
View File
@@ -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();
});