0.1.9
This commit is contained in:
+32
-2
@@ -4,11 +4,15 @@ import {
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
InvalidationsEpoch,
|
||||
} from "../out/indexer/caches.js";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const stat = { mtimeMs: 1, size: 1, birthtimeMs: 1, ctimeMs: 1 };
|
||||
|
||||
function parsed(path, elements) {
|
||||
return {
|
||||
file: { path, stat: { mtimeMs: 1, size: 1 } },
|
||||
file: { path, stat },
|
||||
parse: { root: { name: "r" }, elements: new Array(elements), errors: [] },
|
||||
records: null,
|
||||
lineMap: null,
|
||||
@@ -47,7 +51,7 @@ test("DocumentCache invalidate frees budget", () => {
|
||||
test("IndexRecordsCache stores and invalidates entries", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat: { mtimeMs: 1, size: 1 },
|
||||
stat,
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
kind: "full",
|
||||
};
|
||||
@@ -57,6 +61,20 @@ test("IndexRecordsCache stores and invalidates entries", () => {
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
});
|
||||
|
||||
test("IndexRecordsCache exposes entries for disk persistence", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat,
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
kind: "full",
|
||||
};
|
||||
cache.set("a.xml", entry);
|
||||
const list = [...cache.entries()];
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0][0], resolve("a.xml").toLowerCase());
|
||||
assert.equal(list[0][1], entry);
|
||||
});
|
||||
|
||||
test("IncludeResolveCache stores sources and manifest lookups", () => {
|
||||
const cache = new IncludeResolveCache();
|
||||
const key = "dir|DATA:static.xml";
|
||||
@@ -71,3 +89,15 @@ test("IncludeResolveCache stores sources and manifest lookups", () => {
|
||||
assert.equal(cache.get(key), undefined);
|
||||
assert.equal(cache.getManifest("static.xml"), undefined);
|
||||
});
|
||||
|
||||
test("InvalidationsEpoch tracks changes since a snapshot", () => {
|
||||
const epoch = new InvalidationsEpoch();
|
||||
const before = epoch.snapshot();
|
||||
assert.equal(epoch.changedSince(before), false);
|
||||
epoch.mark();
|
||||
assert.equal(epoch.changedSince(before), true);
|
||||
assert.equal(epoch.changedSince(epoch.snapshot()), false);
|
||||
epoch.mark();
|
||||
epoch.mark();
|
||||
assert.equal(epoch.current, 3);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,8 @@ require.cache["vscode-stub"] = {
|
||||
};
|
||||
|
||||
const { Ra3CompletionProvider } = require("../out/features/completion.js");
|
||||
const { parseXml, LineMap } = require("../out/language/xmlParser.js");
|
||||
const { expandDocument } = require("../out/indexer/logicalTree.js");
|
||||
|
||||
function makeDocument(text) {
|
||||
const lineStarts = [0];
|
||||
@@ -98,9 +100,26 @@ function makeDocument(text) {
|
||||
};
|
||||
}
|
||||
|
||||
// Enum completions do not consult the index, but provideCompletionItems only
|
||||
// routes value contexts when the workspace index is present.
|
||||
const provider = new Ra3CompletionProvider({ index: {} });
|
||||
async function makeScope(text, idx) {
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
const expanded = await expandDocument("test.xml", parse, {
|
||||
resolve: () => null,
|
||||
readDom: async () => null,
|
||||
});
|
||||
return { expanded, merged: idx, overlay: {} };
|
||||
}
|
||||
|
||||
// Enum completions do not consult the index, and value contexts now work
|
||||
// even before the workspace index exists.
|
||||
const makeProvider = (idx) =>
|
||||
new Ra3CompletionProvider({
|
||||
index: idx,
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async (document) => makeScope(document.getText(), idx),
|
||||
});
|
||||
const provider = makeProvider({});
|
||||
const providerNoIndex = makeProvider(null);
|
||||
const token = { isCancellationRequested: false };
|
||||
|
||||
test("Surfaces enum completion works with an unclosed quote", async () => {
|
||||
@@ -155,3 +174,73 @@ test("empty unterminated value offers all enum values", async () => {
|
||||
assert.ok(labels.includes("WATER"));
|
||||
assert.ok(labels.includes("CRUSHABLE_WALL"));
|
||||
});
|
||||
|
||||
test("enum completions work without an index", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="G>\n <Other/>\n</LocomotorTemplate>\n</AssetDeclaration>`;
|
||||
const line1 = text.split("\n")[1];
|
||||
const pos = new Position(1, line1.indexOf("G") + 1);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("GROUND"), "enum value offered without an index");
|
||||
assert.ok(!labels.includes("WATER"));
|
||||
});
|
||||
|
||||
test("element and attribute name completions work without an index", async () => {
|
||||
const text = `<AssetDeclaration>\n <LocomotorTemplate `;
|
||||
const line1 = text.split("\n")[1];
|
||||
const pos = new Position(1, line1.length);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("id"));
|
||||
assert.ok(labels.includes("Surfaces"));
|
||||
});
|
||||
|
||||
test("content (child element) completions work without an index", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n <LocomotorTemplate id="x">\n \n </LocomotorTemplate>\n</AssetDeclaration>`;
|
||||
const pos = new Position(2, 4);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.length > 0, "child elements offered without an index");
|
||||
});
|
||||
|
||||
test("Poid attributes offer ids from the enclosing GameObject's local scope", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <GameObject id="Tank">\n` +
|
||||
` <Draws>\n` +
|
||||
` <TruckDraw id="ModuleTag_Draw" />\n` +
|
||||
` </Draws>\n` +
|
||||
` <BehaviorModules>\n` +
|
||||
` <ReconstituteStateSpecialAbility UpdateModuleId="ModuleTag_D">\n` +
|
||||
` </BehaviorModules>\n` +
|
||||
` </GameObject>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line6 = text.split("\n")[6];
|
||||
const pos = new Position(6, line6.indexOf("ModuleTag_D") + "ModuleTag_D".length);
|
||||
|
||||
const items = await provider.provideCompletionItems(makeDocument(text), pos, token);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("ModuleTag_Draw"));
|
||||
assert.ok(items.every((i) => i.kind === CompletionItemKind.Value));
|
||||
assert.ok(
|
||||
items.every((i) => i.detail === "local module"),
|
||||
"Poid completions are labelled as local-scope ids",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { DiskRecordsCache, diskCacheKey } from "../out/indexer/diskCache.js";
|
||||
|
||||
const identity = {
|
||||
projectDir: "C:/proj",
|
||||
sdkDir: "C:/sdk",
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
builtmodsDirs: ["C:/sdk/builtmods"],
|
||||
};
|
||||
|
||||
const sampleRecords = {
|
||||
assets: [{ type: "GameObject", id: "TankA", line: 3 }],
|
||||
defines: [{ name: "HP", value: "100", line: 2 }],
|
||||
includes: [{ type: "all", source: "Units.xml", line: 4 }],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
};
|
||||
|
||||
function stampOf(file) {
|
||||
const s = fs.statSync(file);
|
||||
return {
|
||||
mtimeMs: s.mtimeMs,
|
||||
size: s.size,
|
||||
birthtimeMs: s.birthtimeMs,
|
||||
ctimeMs: s.ctimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTmp(t) {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-diskcache-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
return tmp;
|
||||
}
|
||||
|
||||
test("disk cache roundtrip keeps records and leaves no temp file", 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" },
|
||||
],
|
||||
]);
|
||||
assert.equal(fs.existsSync(`${filePath}.tmp`), false, "atomic write leaves no temp");
|
||||
|
||||
const { records, stats } = await cache.loadValidated();
|
||||
assert.equal(stats.fileExists, true);
|
||||
assert.equal(stats.keyMatched, true);
|
||||
assert.equal(stats.loaded, 1);
|
||||
assert.equal(stats.validated, 1);
|
||||
assert.equal(stats.dropped, 0);
|
||||
assert.equal(records.length, 1);
|
||||
assert.deepEqual(records[0].records, sampleRecords);
|
||||
});
|
||||
|
||||
test("stat mismatch drops the cached entry", 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 past = new Date(Date.now() - 60000);
|
||||
fs.utimesSync(file, past, past);
|
||||
const { records, stats } = await cache.loadValidated();
|
||||
assert.equal(stats.validated, 0);
|
||||
assert.equal(stats.dropped, 1);
|
||||
assert.equal(records.length, 0);
|
||||
});
|
||||
|
||||
test("identity mismatch ignores the cache", 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 other = new DiskRecordsCache(filePath, {
|
||||
...identity,
|
||||
sdkDir: "D:/other-sdk",
|
||||
});
|
||||
const { records, stats } = await other.loadValidated();
|
||||
assert.equal(stats.fileExists, true);
|
||||
assert.equal(stats.keyMatched, false);
|
||||
assert.equal(records.length, 0);
|
||||
});
|
||||
|
||||
test("corrupt cache file yields an empty result", async (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const filePath = join(tmp, "index-records.json.gz");
|
||||
fs.writeFileSync(filePath, "this is not gzip json");
|
||||
const cache = new DiskRecordsCache(filePath, identity);
|
||||
const { records, stats } = await cache.loadValidated();
|
||||
assert.equal(stats.fileExists, true);
|
||||
assert.equal(records.length, 0);
|
||||
});
|
||||
|
||||
test("clear removes the cache file", 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" }],
|
||||
]);
|
||||
assert.ok(fs.existsSync(filePath));
|
||||
await cache.clear();
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
});
|
||||
|
||||
test("diskCacheKey differs when the identity changes", () => {
|
||||
const a = diskCacheKey(identity);
|
||||
const b = diskCacheKey({ ...identity, indexSageXml: false });
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a, diskCacheKey(identity));
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, parse, resolve } from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { resolveSource } from "../out/indexer/includeResolver.js";
|
||||
import {
|
||||
ExistenceSnapshot,
|
||||
buildExistenceSnapshot,
|
||||
isDriveRoot,
|
||||
} from "../out/indexer/existence.js";
|
||||
|
||||
function makeTmp(t) {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-existence-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
return tmp;
|
||||
}
|
||||
|
||||
test("isDriveRoot detects filesystem roots", () => {
|
||||
assert.equal(isDriveRoot(parse(process.cwd()).root), true);
|
||||
assert.equal(isDriveRoot(process.cwd()), false);
|
||||
});
|
||||
|
||||
test("ExistenceSnapshot answers covered paths and falls back outside roots", (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const dataDir = join(tmp, "data");
|
||||
fs.mkdirSync(dataDir);
|
||||
const existing = join(dataDir, "Units.xml");
|
||||
fs.writeFileSync(existing, "<x/>");
|
||||
|
||||
const snap = new ExistenceSnapshot([dataDir]);
|
||||
assert.equal(snap.has(existing), true);
|
||||
assert.equal(snap.has(join(dataDir, "Missing.xml")), false);
|
||||
assert.equal(snap.has(join(tmp, "outside.xml")), null, "outside roots is unknown");
|
||||
assert.ok(snap.hits >= 2, "covered lookups counted as hits");
|
||||
assert.equal(snap.fallbacks, 1);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(
|
||||
snap.has(existing.toUpperCase()),
|
||||
true,
|
||||
"lookup is case-insensitive on Windows",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildExistenceSnapshot covers bounded search bases lazily", async (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const dataDir = join(tmp, "data");
|
||||
const artDir = join(tmp, "art");
|
||||
fs.mkdirSync(join(dataDir, "sub"), { recursive: true });
|
||||
fs.mkdirSync(artDir);
|
||||
fs.writeFileSync(join(dataDir, "Units.xml"), "<x/>");
|
||||
fs.writeFileSync(join(dataDir, "sub", "Nested.xml"), "<x/>");
|
||||
fs.writeFileSync(join(artDir, "Tank.w3x"), "<x/>");
|
||||
|
||||
const snap = buildExistenceSnapshot({
|
||||
DATA: [dataDir],
|
||||
ART: [artDir],
|
||||
AUDIO: [],
|
||||
});
|
||||
assert.equal(snap.has(join(dataDir, "Units.xml")), true);
|
||||
assert.equal(snap.has(join(dataDir, "sub", "Nested.xml")), true);
|
||||
assert.equal(snap.has(join(artDir, "Tank.w3x")), true);
|
||||
assert.equal(snap.has(join(dataDir, "Missing.xml")), false);
|
||||
});
|
||||
|
||||
test("resolveSource uses the snapshot and falls back to statSync outside it", async (t) => {
|
||||
const tmp = makeTmp(t);
|
||||
const dataDir = join(tmp, "data");
|
||||
const outsideDir = join(tmp, "outside");
|
||||
fs.mkdirSync(dataDir);
|
||||
fs.mkdirSync(outsideDir);
|
||||
const units = join(dataDir, "Units.xml");
|
||||
const outside = join(outsideDir, "Extra.xml");
|
||||
fs.writeFileSync(units, "<x/>");
|
||||
fs.writeFileSync(outside, "<x/>");
|
||||
|
||||
const searchPaths = { DATA: [dataDir], ART: [], AUDIO: [] };
|
||||
const snap = buildExistenceSnapshot(searchPaths);
|
||||
|
||||
const found = resolveSource("DATA:Units.xml", null, searchPaths, snap);
|
||||
assert.equal(found.path, units);
|
||||
assert.ok(snap.hits > 0, "covered lookup served by the snapshot");
|
||||
|
||||
const missing = resolveSource("DATA:Missing.xml", null, searchPaths, snap);
|
||||
assert.equal(missing.path, null);
|
||||
|
||||
const emptySearchPaths = { DATA: [], ART: [], AUDIO: [] };
|
||||
const outsideResolved = resolveSource(
|
||||
outside,
|
||||
outsideDir,
|
||||
emptySearchPaths,
|
||||
snap,
|
||||
);
|
||||
assert.equal(
|
||||
resolve(outsideResolved.path ?? ""),
|
||||
resolve(outside),
|
||||
"uncovered path falls back to statSync",
|
||||
);
|
||||
assert.ok(snap.fallbacks > 0, "fallback counted");
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
isContentRelevantPath,
|
||||
isWatcherNoisePath,
|
||||
} from "../out/indexer/fileScanner.js";
|
||||
|
||||
test("isWatcherNoisePath filters .git internals", () => {
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", ".git", "index")),
|
||||
true,
|
||||
".git files are noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "data", ".git", "FETCH_HEAD")),
|
||||
true,
|
||||
"nested .git directories are noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "Data", "Mod.xml")),
|
||||
false,
|
||||
"project XML is not noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", ".gitignore")),
|
||||
false,
|
||||
".gitignore is a real project file",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "Data", "UnitCrate.xml.git")),
|
||||
true,
|
||||
"editor temp files ending in .git are noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "Data", "UnitCrate.xml.tmp")),
|
||||
true,
|
||||
".tmp files are noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "Data", "Mod.xml~")),
|
||||
true,
|
||||
"backup files ending in ~ are noise",
|
||||
);
|
||||
assert.equal(
|
||||
isWatcherNoisePath(join("C:/proj", "Data", ".#Mod.xml")),
|
||||
true,
|
||||
"lock files starting with .# are noise",
|
||||
);
|
||||
});
|
||||
|
||||
test("isContentRelevantPath only reacts to XML-ish content", () => {
|
||||
assert.equal(isContentRelevantPath("a.xml"), true);
|
||||
assert.equal(isContentRelevantPath("a.w3x"), true);
|
||||
assert.equal(
|
||||
isContentRelevantPath("a.manifestxml"),
|
||||
false,
|
||||
"there is no .manifestxml source format",
|
||||
);
|
||||
assert.equal(
|
||||
isContentRelevantPath("a.w3d"),
|
||||
false,
|
||||
".w3d is binary art, not text XML",
|
||||
);
|
||||
assert.equal(isContentRelevantPath("a.dds"), false);
|
||||
assert.equal(isContentRelevantPath("a.xml.git"), false);
|
||||
assert.equal(
|
||||
isContentRelevantPath("a.lua"),
|
||||
false,
|
||||
"lua is not indexed yet",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<HeadlightDraw2>
|
||||
<TruckDraw id="ModuleTag_Headlight" />
|
||||
</HeadlightDraw2>
|
||||
</AssetDeclaration>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="StandaloneBase" Side="Allies" />
|
||||
</AssetDeclaration>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="all" source="Models/Tank_SKN.w3x" />
|
||||
<Include type="all" source="Models/Tank_FP.w3d" />
|
||||
<Include type="all" source="Models/Tank_FP.dat" />
|
||||
<Include type="all" source="Models/Tank_Damaged.dds" />
|
||||
</Includes>
|
||||
</AssetDeclaration>
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="instance" source="Includes/StandaloneBase.xml" />
|
||||
</Includes>
|
||||
<Defines>
|
||||
<Define name="STANDALONE_HEALTH" value="200.0" />
|
||||
</Defines>
|
||||
<GameObject id="StandaloneTank" inheritFrom="StandaloneBase">
|
||||
<Draws>
|
||||
<TruckDraw id="ModuleTag_Draw" />
|
||||
<xi:include
|
||||
href="DATA:Includes/HeadlightModules.xml"
|
||||
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)" />
|
||||
</Draws>
|
||||
<BehaviorModules>
|
||||
<ReconstituteStateSpecialAbility UpdateModuleId="ModuleTag_Headlight" />
|
||||
</BehaviorModules>
|
||||
</GameObject>
|
||||
</AssetDeclaration>
|
||||
+105
-2
@@ -79,7 +79,7 @@ test("provides include source candidates", async () => {
|
||||
assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
|
||||
});
|
||||
|
||||
test("indexes art-asset XML (.w3x / sniffed .w3d) via shallow scan and skips binary", async () => {
|
||||
test("indexes art-asset XML (.w3x / sniffed unknown extension) via shallow scan and skips binary", async () => {
|
||||
const idx = await buildIndex();
|
||||
|
||||
// The .w3x hub chain: Mod.xml -> VehicleArt.xml -> Models/Tank_SKN.w3x.
|
||||
@@ -96,7 +96,7 @@ test("indexes art-asset XML (.w3x / sniffed .w3d) via shallow scan and skips bin
|
||||
// Unknown extension with XML content is sniffed and indexed.
|
||||
assert.ok(
|
||||
idx.assetsById.get("tank_fp")?.some((d) => d.type === "W3DMesh"),
|
||||
"unknown-extension XML (.w3d) sniffed and indexed",
|
||||
"unknown-extension XML (.dat) sniffed and indexed",
|
||||
);
|
||||
|
||||
// Binary content is registered as a file but never parsed.
|
||||
@@ -133,6 +133,107 @@ test("w3x files appear in Include source completion candidates", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("build publishes an immutable XML phase before art scanning", async () => {
|
||||
let phaseA;
|
||||
const indexer = new ModIndexer({
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: true,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
});
|
||||
const idx = await indexer.build((p) => {
|
||||
phaseA = p;
|
||||
});
|
||||
|
||||
assert.ok(phaseA, "phase-A snapshot published");
|
||||
assert.equal(phaseA.complete, false);
|
||||
assert.equal(phaseA.phase, "xml");
|
||||
assert.ok(phaseA.assetsById.has("testtank"), "XML assets available in phase A");
|
||||
assert.ok(
|
||||
phaseA.assetsById.has("vanillatank"),
|
||||
"manifest assets available in phase A",
|
||||
);
|
||||
assert.equal(
|
||||
phaseA.assetsById.has("tank_skn"),
|
||||
false,
|
||||
"art assets deferred in phase A",
|
||||
);
|
||||
assert.ok(phaseA.stats.deferredArtFiles >= 2, "deferred art queue recorded");
|
||||
assert.equal(phaseA.stats.shallowScannedFiles, 0, "no art scanned during phase A");
|
||||
|
||||
assert.equal(idx.complete, true);
|
||||
assert.equal(idx.phase, "art");
|
||||
assert.ok(idx.assetsById.has("tank_skn"), "art assets present in the final index");
|
||||
assert.equal(
|
||||
phaseA.assetsById.has("tank_skn"),
|
||||
false,
|
||||
"phase-A snapshot is immutable (phase B did not mutate it)",
|
||||
);
|
||||
assert.equal(typeof idx.stats.artScanMs, "number");
|
||||
assert.equal(
|
||||
indexer.isIndexedFile(join(project, "Data", "Mod.xml")),
|
||||
true,
|
||||
"indexed files are recognized",
|
||||
);
|
||||
assert.equal(
|
||||
indexer.isIndexedFile(join(project, "Data", "NotIndexed.xml")),
|
||||
false,
|
||||
"unrelated files are not recognized",
|
||||
);
|
||||
});
|
||||
|
||||
test("stat validation re-reads a file whose mtime changed", async (t) => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3modxml-mtime-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const projectDir = join(tmp, "project");
|
||||
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
|
||||
const modPath = join(projectDir, "Data", "Mod.xml");
|
||||
fs.writeFileSync(
|
||||
modPath,
|
||||
`<?xml version="1.0"?>\n<AssetDeclaration>\n <GameObject id="TankA"/>\n</AssetDeclaration>\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const documentCache = new DocumentCache();
|
||||
const recordsCache = new IndexRecordsCache();
|
||||
const resolveCache = new IncludeResolveCache();
|
||||
const make = () =>
|
||||
new ModIndexer({
|
||||
projectDir,
|
||||
sdkDir: sdk,
|
||||
builtmodsDirs: [join(sdk, "builtmods")],
|
||||
indexSageXml: false,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
documentCache,
|
||||
recordsCache,
|
||||
resolveCache,
|
||||
trustUnchanged: false,
|
||||
});
|
||||
|
||||
const first = await make().build();
|
||||
assert.ok(first.assetsById.has("tanka"));
|
||||
assert.equal(first.stats.recordsCacheHits, 0);
|
||||
|
||||
const past = new Date(Date.now() - 60000);
|
||||
fs.utimesSync(modPath, past, past);
|
||||
const second = await make().build();
|
||||
assert.equal(
|
||||
second.stats.recordsCacheHits,
|
||||
0,
|
||||
"mtime change invalidates the cached records",
|
||||
);
|
||||
assert.ok(second.assetsById.has("tanka"));
|
||||
|
||||
const third = await make().build();
|
||||
assert.ok(
|
||||
third.stats.recordsCacheHits > 0,
|
||||
"unchanged files are served from the records cache",
|
||||
);
|
||||
});
|
||||
|
||||
test("shallow scans and full parses are cached across rebuilds", async () => {
|
||||
const documentCache = new DocumentCache();
|
||||
const recordsCache = new IndexRecordsCache();
|
||||
@@ -218,6 +319,8 @@ test("index stats include candidate/walk phase timings", async () => {
|
||||
const idx = await buildIndex();
|
||||
assert.equal(typeof idx.stats.candidatesMs, "number");
|
||||
assert.equal(typeof idx.stats.walkMs, "number");
|
||||
assert.ok(idx.stats.snapshotHits > 0, "existence snapshot answered lookups");
|
||||
assert.equal(typeof idx.stats.snapshotFallbacks, "number");
|
||||
});
|
||||
|
||||
test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseXml, LineMap, stripBom } from "../out/language/xmlParser.js";
|
||||
import { extractIndexRecords } from "../out/indexer/records.js";
|
||||
import { buildSearchPaths } from "../out/indexer/includeResolver.js";
|
||||
import {
|
||||
buildDocumentScope,
|
||||
withLocalOverlay,
|
||||
} from "../out/indexer/localScope.js";
|
||||
import {
|
||||
findContainingGameObject,
|
||||
findLocalId,
|
||||
collectLocalIds,
|
||||
} from "../out/indexer/logicalTree.js";
|
||||
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
||||
import { resolveElementType } from "../out/language/typeContext.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const project = join(root, "test", "fixtures", "minimod");
|
||||
const sdk = join(root, "test", "fixtures", "fakesdk");
|
||||
const standalonePath = join(project, "Data", "Standalone.xml");
|
||||
|
||||
async function readParsed(path) {
|
||||
const text = stripBom(await readFile(path, "utf8"));
|
||||
const lineMap = new LineMap(text);
|
||||
const parse = parseXml(text);
|
||||
return {
|
||||
file: { path, stat: null },
|
||||
parse,
|
||||
records: extractIndexRecords(parse, lineMap),
|
||||
lineMap,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeScope() {
|
||||
const searchPaths = buildSearchPaths(sdk, project);
|
||||
const text = await readFile(standalonePath, "utf8");
|
||||
return buildDocumentScope(standalonePath, text, 1, {
|
||||
projectDir: project,
|
||||
sdkDir: sdk,
|
||||
searchPaths,
|
||||
readRecords: readParsed,
|
||||
readDom: readParsed,
|
||||
});
|
||||
}
|
||||
|
||||
test("local overlay resolves refs for a file outside every global stream", async () => {
|
||||
const scope = await makeScope();
|
||||
const merged = withLocalOverlay(null, scope.overlay, project, sdk);
|
||||
|
||||
assert.ok(merged.local.assetsById.has("standalonetank"));
|
||||
assert.ok(merged.local.assetsById.has("standalonebase"));
|
||||
assert.ok(merged.local.defines.has("standalone_health"));
|
||||
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
merged,
|
||||
"GameObject",
|
||||
"inheritFrom",
|
||||
"StandaloneBase",
|
||||
);
|
||||
assert.equal(targets.length, 1);
|
||||
assert.match(targets[0].def.file, /StandaloneBase\.xml$/);
|
||||
assert.equal(targets[0].def.origin, "project");
|
||||
});
|
||||
|
||||
test("logical xi:include expansion gives included modules their Draws context", async () => {
|
||||
const scope = await makeScope();
|
||||
const included = scope.expanded.elements.find(
|
||||
(e) =>
|
||||
e.name === "TruckDraw" &&
|
||||
e.attrs.some((a) => a.name === "id" && a.value === "ModuleTag_Headlight"),
|
||||
);
|
||||
assert.ok(included, "xi:include target spliced into the logical tree");
|
||||
assert.match(included.sourceFile, /HeadlightModules\.xml$/i);
|
||||
assert.equal(
|
||||
resolveElementType(included),
|
||||
"W3DTruckDrawModuleData",
|
||||
"included module resolves through the logical Draws parent",
|
||||
);
|
||||
|
||||
const update = scope.expanded.elements.find(
|
||||
(e) => e.name === "ReconstituteStateSpecialAbility",
|
||||
);
|
||||
assert.ok(update);
|
||||
const gameObject = findContainingGameObject(update);
|
||||
assert.equal(gameObject?.name, "GameObject");
|
||||
assert.equal(
|
||||
findLocalId(gameObject, "ModuleTag_Headlight"),
|
||||
included,
|
||||
"Poid reference can reach a module spliced in through xi:include",
|
||||
);
|
||||
|
||||
const localIds = collectLocalIds(gameObject).map((i) => i.id);
|
||||
assert.ok(localIds.includes("ModuleTag_Draw"));
|
||||
assert.ok(localIds.includes("ModuleTag_Headlight"));
|
||||
});
|
||||
|
||||
test("local overlay wins over a global definition with the same id", async () => {
|
||||
const scope = await makeScope();
|
||||
const global = {
|
||||
assets: new Map(),
|
||||
assetsById: new Map([
|
||||
[
|
||||
"standalonebase",
|
||||
[
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "StandaloneBase",
|
||||
file: join(sdk, "SageXml", "VanillaBase.xml"),
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
defines: new Map(),
|
||||
};
|
||||
const merged = withLocalOverlay(global, scope.overlay, project, sdk);
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
merged,
|
||||
"GameObject",
|
||||
"inheritFrom",
|
||||
"StandaloneBase",
|
||||
);
|
||||
assert.equal(targets.length, 2);
|
||||
assert.match(targets[0].def.file, /StandaloneBase\.xml$/);
|
||||
assert.equal(targets[0].def.origin, "project");
|
||||
assert.match(targets[1].def.file, /VanillaBase\.xml$/);
|
||||
});
|
||||
|
||||
test("logical expansion terminates on xi:include cycles", async (t) => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-cycle-"));
|
||||
t.after(() => rm(tmp, { recursive: true, force: true }));
|
||||
const dataDir = join(tmp, "Data");
|
||||
const includesDir = join(dataDir, "Includes");
|
||||
await mkdir(includesDir, { recursive: true });
|
||||
const aPath = join(dataDir, "A.xml");
|
||||
const bPath = join(includesDir, "B.xml");
|
||||
await writeFile(
|
||||
aPath,
|
||||
`<AssetDeclaration><GameObject id="A"><xi:include href="Includes/B.xml"/></GameObject></AssetDeclaration>`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
bPath,
|
||||
`<AssetDeclaration><GameObject id="B"><xi:include href="../A.xml"/></GameObject></AssetDeclaration>`,
|
||||
"utf8",
|
||||
);
|
||||
const searchPaths = buildSearchPaths(sdk, tmp);
|
||||
const text = await readFile(aPath, "utf8");
|
||||
const scope = await buildDocumentScope(aPath, text, 1, {
|
||||
projectDir: tmp,
|
||||
sdkDir: sdk,
|
||||
searchPaths,
|
||||
readRecords: readParsed,
|
||||
readDom: readParsed,
|
||||
});
|
||||
assert.ok(
|
||||
scope.expanded.elements.some(
|
||||
(e) =>
|
||||
e.name === "GameObject" &&
|
||||
e.attrs.some((a) => a.name === "id" && a.value === "A"),
|
||||
),
|
||||
"entry document survives a cycle",
|
||||
);
|
||||
});
|
||||
|
||||
test("the same xi:include target can expand under multiple parents", async (t) => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-shared-"));
|
||||
t.after(() => rm(tmp, { recursive: true, force: true }));
|
||||
const dataDir = join(tmp, "Data");
|
||||
const includesDir = join(dataDir, "Includes");
|
||||
await mkdir(includesDir, { recursive: true });
|
||||
const mainPath = join(dataDir, "Main.xml");
|
||||
const fragmentPath = join(includesDir, "Fragment.xml");
|
||||
await writeFile(
|
||||
fragmentPath,
|
||||
`<AssetDeclaration><HeadlightDraw2><TruckDraw id="ModuleTag_Shared"/></HeadlightDraw2></AssetDeclaration>`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
mainPath,
|
||||
`<AssetDeclaration>` +
|
||||
`<GameObject id="A"><Draws><xi:include href="DATA:Includes/Fragment.xml" ` +
|
||||
`xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)"/></Draws></GameObject>` +
|
||||
`<GameObject id="B"><Draws><xi:include href="DATA:Includes/Fragment.xml" ` +
|
||||
`xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)"/></Draws></GameObject>` +
|
||||
`</AssetDeclaration>`,
|
||||
"utf8",
|
||||
);
|
||||
const searchPaths = buildSearchPaths(sdk, tmp);
|
||||
const text = await readFile(mainPath, "utf8");
|
||||
const scope = await buildDocumentScope(mainPath, text, 1, {
|
||||
projectDir: tmp,
|
||||
sdkDir: sdk,
|
||||
searchPaths,
|
||||
readRecords: readParsed,
|
||||
readDom: readParsed,
|
||||
});
|
||||
const shared = scope.expanded.elements.filter(
|
||||
(e) =>
|
||||
e.name === "TruckDraw" &&
|
||||
e.attrs.some((a) => a.name === "id" && a.value === "ModuleTag_Shared"),
|
||||
);
|
||||
assert.equal(shared.length, 2, "same fragment expands once per parent");
|
||||
});
|
||||
@@ -130,13 +130,13 @@ test("fallback tokens cover names, attributes and values", () => {
|
||||
|
||||
test("well-formed XML gets no semantic fallback tokens", async () => {
|
||||
const text = `<AssetDeclaration>\n <LocomotorTemplate id="x" Surfaces="GROUND"/>\n</AssetDeclaration>`;
|
||||
const provider = new Ra3SemanticTokensProvider();
|
||||
const provider = new Ra3SemanticTokensProvider({ isRa3Workspace: () => true });
|
||||
const result = await provider.provideDocumentSemanticTokens(makeDocument(text), {});
|
||||
assert.equal(result.data.length, 0);
|
||||
});
|
||||
|
||||
test("malformed XML gets semantic fallback tokens", async () => {
|
||||
const provider = new Ra3SemanticTokensProvider();
|
||||
const provider = new Ra3SemanticTokensProvider({ isRa3Workspace: () => true });
|
||||
const result = await provider.provideDocumentSemanticTokens(
|
||||
makeDocument(MALFORMED),
|
||||
{},
|
||||
|
||||
Reference in New Issue
Block a user