0.1.1
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
} from "../out/indexer/caches.js";
|
||||
|
||||
function parsed(path, elements) {
|
||||
return {
|
||||
file: { path, stat: { mtimeMs: 1, size: 1 } },
|
||||
parse: { root: { name: "r" }, elements: new Array(elements), errors: [] },
|
||||
records: null,
|
||||
lineMap: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("DocumentCache evicts the largest tree when over the element budget", () => {
|
||||
const cache = new DocumentCache(64, 100);
|
||||
cache.set(parsed("a.xml", 60));
|
||||
cache.set(parsed("b.xml", 60));
|
||||
assert.equal(cache.get("a.xml"), undefined, "largest tree evicted first");
|
||||
assert.ok(cache.get("b.xml"));
|
||||
});
|
||||
|
||||
test("DocumentCache evicts least recently used when over capacity", () => {
|
||||
const cache = new DocumentCache(2, 1_000_000);
|
||||
cache.set(parsed("a.xml", 10));
|
||||
cache.set(parsed("b.xml", 10));
|
||||
cache.set(parsed("c.xml", 10));
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
assert.ok(cache.get("b.xml"));
|
||||
assert.ok(cache.get("c.xml"));
|
||||
});
|
||||
|
||||
test("DocumentCache invalidate frees budget", () => {
|
||||
const cache = new DocumentCache(64, 100);
|
||||
cache.set(parsed("a.xml", 60));
|
||||
cache.invalidate("a.xml");
|
||||
cache.set(parsed("b.xml", 60));
|
||||
assert.ok(cache.get("b.xml"), "invalidating a freed its budget share");
|
||||
cache.set(parsed("c.xml", 60));
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
assert.ok(cache.get("c.xml"));
|
||||
});
|
||||
|
||||
test("IndexRecordsCache stores and invalidates entries", () => {
|
||||
const cache = new IndexRecordsCache();
|
||||
const entry = {
|
||||
stat: { mtimeMs: 1, size: 1 },
|
||||
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
|
||||
kind: "full",
|
||||
};
|
||||
cache.set("a.xml", entry);
|
||||
assert.equal(cache.get("a.xml"), entry);
|
||||
cache.invalidate("a.xml");
|
||||
assert.equal(cache.get("a.xml"), undefined);
|
||||
});
|
||||
|
||||
test("IncludeResolveCache stores sources and manifest lookups", () => {
|
||||
const cache = new IncludeResolveCache();
|
||||
const key = "dir|DATA:static.xml";
|
||||
const result = { path: "C:/sdk/static.xml", prefix: "DATA", raw: "DATA:static.xml" };
|
||||
assert.equal(cache.get(key), undefined);
|
||||
cache.set(key, result);
|
||||
assert.equal(cache.get(key), result);
|
||||
assert.equal(cache.getManifest("static.xml"), undefined);
|
||||
cache.setManifest("static.xml", "C:/sdk/builtmods/static.manifest");
|
||||
assert.equal(cache.getManifest("static.xml"), "C:/sdk/builtmods/static.manifest");
|
||||
cache.clear();
|
||||
assert.equal(cache.get(key), undefined);
|
||||
assert.equal(cache.getManifest("static.xml"), undefined);
|
||||
});
|
||||
+69
-3
@@ -6,7 +6,11 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { ModIndexer } from "../out/indexer/indexer.js";
|
||||
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
|
||||
import { DocumentCache, ShallowScanCache } from "../out/indexer/caches.js";
|
||||
import {
|
||||
DocumentCache,
|
||||
IncludeResolveCache,
|
||||
IndexRecordsCache,
|
||||
} from "../out/indexer/caches.js";
|
||||
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
@@ -131,7 +135,8 @@ test("w3x files appear in Include source completion candidates", async () => {
|
||||
|
||||
test("shallow scans and full parses are cached across rebuilds", async () => {
|
||||
const documentCache = new DocumentCache();
|
||||
const shallowCache = new ShallowScanCache();
|
||||
const recordsCache = new IndexRecordsCache();
|
||||
const resolveCache = new IncludeResolveCache();
|
||||
const makeIndexer = () =>
|
||||
new ModIndexer({
|
||||
projectDir: project,
|
||||
@@ -141,7 +146,8 @@ test("shallow scans and full parses are cached across rebuilds", async () => {
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
documentCache,
|
||||
shallowCache,
|
||||
recordsCache,
|
||||
resolveCache,
|
||||
});
|
||||
|
||||
const first = await makeIndexer().build();
|
||||
@@ -150,10 +156,70 @@ test("shallow scans and full parses are cached across rebuilds", async () => {
|
||||
assert.equal(first.stats.shallowScannedFiles, 2, "w3x + sniffed w3d scanned on first build");
|
||||
assert.equal(second.stats.shallowScannedFiles, 0, "unchanged art assets are not re-scanned");
|
||||
assert.equal(second.stats.shallowCacheHits, 2);
|
||||
assert.ok(second.stats.recordsCacheHits > 0, "XML records served from cache");
|
||||
assert.ok(second.stats.resolveCacheHits > 0, "include resolutions served from cache");
|
||||
assert.equal(second.stats.resolveCalls, 0, "no include re-resolved on a trusted rebuild");
|
||||
assert.equal(second.stats.assetCount, first.stats.assetCount);
|
||||
assert.equal(second.stats.indexedFiles, first.stats.indexedFiles);
|
||||
});
|
||||
|
||||
test("trusted rebuilds skip unchanged files; invalidation forces re-reads", 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,
|
||||
});
|
||||
|
||||
// Trusted rebuild: cached files are reused without any per-file stat/read.
|
||||
const first = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(first.stats.shallowScannedFiles, 2);
|
||||
const second = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(second.stats.shallowScannedFiles, 0);
|
||||
assert.equal(second.stats.shallowCacheHits, 2);
|
||||
assert.ok(second.stats.recordsCacheHits > 0);
|
||||
|
||||
// Simulate the file watcher / save handler: invalidate one art asset.
|
||||
// The next trusted rebuild must re-scan exactly that file.
|
||||
recordsCache.invalidate(join(project, "Data", "Includes", "Models", "Tank_SKN.w3x"));
|
||||
const third = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(third.stats.shallowScannedFiles, 1);
|
||||
assert.equal(third.stats.shallowCacheHits, 1);
|
||||
assert.ok(
|
||||
third.assetsById.get("tank_skn")?.some((d) => d.type === "W3DContainer"),
|
||||
"re-scanned w3x asset present",
|
||||
);
|
||||
|
||||
// Invalidate one XML file: exactly its records are re-extracted.
|
||||
recordsCache.invalidate(join(project, "Data", "Includes", "Units.xml"));
|
||||
const fourth = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
|
||||
assert.equal(fourth.stats.recordsCacheHits, third.stats.recordsCacheHits - 1);
|
||||
assert.ok(
|
||||
fourth.assetsById.get("testtank")?.some((d) => d.type === "GameObject"),
|
||||
"re-parsed XML asset present",
|
||||
);
|
||||
|
||||
// A forced rebuild (ra3modxml.reindex) verifies stats but still reuses
|
||||
// content caches for unchanged files.
|
||||
const forced = await new ModIndexer({ ...opts(), trustUnchanged: false }).build();
|
||||
assert.equal(forced.stats.shallowScannedFiles, 0);
|
||||
assert.equal(forced.stats.shallowCacheHits, 2);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3modxml-bom-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseXml, LineMap } from "../out/language/xmlParser.js";
|
||||
import { extractIndexRecords, recordsFromShallow } from "../out/indexer/records.js";
|
||||
import { scanXmlShallow } from "../out/indexer/shallowScan.js";
|
||||
|
||||
test("extractIndexRecords mirrors the walk semantics", () => {
|
||||
const text = `<?xml version="1.0"?>
|
||||
<AssetDeclaration>
|
||||
<Defines>
|
||||
<Define name="HP" value="100"/>
|
||||
</Defines>
|
||||
<Includes>
|
||||
<Include type="all" source="Units.xml"/>
|
||||
<Include type="instance" source="Base.xml"/>
|
||||
</Includes>
|
||||
<GameObject id="Tank" CommandSet="TankCommandSet"/>
|
||||
<WeaponTemplate id="TankGun"/>
|
||||
<xi:include href="DATA:Extra.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:Extra/child::*)"/>
|
||||
<GameObject id="Tank2">
|
||||
<Draws>
|
||||
<xi:include href="DATA:Nested.xml"/>
|
||||
</Draws>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap);
|
||||
assert.deepEqual(
|
||||
records.assets.map((a) => [a.type, a.id, a.line]),
|
||||
[
|
||||
["GameObject", "Tank", 10],
|
||||
["WeaponTemplate", "TankGun", 11],
|
||||
["GameObject", "Tank2", 13],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.defines.map((d) => [d.name, d.value]),
|
||||
[["HP", "100"]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.includes.map((i) => [i.type, i.source]),
|
||||
[
|
||||
["all", "Units.xml"],
|
||||
["instance", "Base.xml"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.rootXiIncludes.map((x) => [x.href, x.line]),
|
||||
[["DATA:Extra.xml", 12]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
records.nestedXiIncludes.map((x) => [x.href, x.line]),
|
||||
[["DATA:Nested.xml", 15]],
|
||||
);
|
||||
});
|
||||
|
||||
test("recordsFromShallow converts offsets to 1-based lines", () => {
|
||||
const text = `<AssetDeclaration>\n <W3DContainer id="A"/>\n</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
|
||||
assert.equal(records.assets.length, 1);
|
||||
assert.equal(records.assets[0].type, "W3DContainer");
|
||||
assert.equal(records.assets[0].id, "A");
|
||||
assert.equal(records.assets[0].line, 2);
|
||||
});
|
||||
@@ -33,9 +33,10 @@ test("extracts top-level assets, includes, defines and xi:include", () => {
|
||||
assert.equal(doc.defines.length, 1);
|
||||
assert.equal(doc.defines[0].name, "MODEL_SCALE");
|
||||
assert.equal(doc.defines[0].value, "1.0");
|
||||
assert.equal(doc.xiIncludes.length, 1);
|
||||
assert.equal(doc.xiIncludes[0].href, "DATA:Includes/Extra.xml");
|
||||
assert.match(doc.xiIncludes[0].xpointer, /xpointer\(/);
|
||||
assert.equal(doc.rootXiIncludes.length, 1);
|
||||
assert.equal(doc.rootXiIncludes[0].href, "DATA:Includes/Extra.xml");
|
||||
assert.match(doc.rootXiIncludes[0].xpointer, /xpointer\(/);
|
||||
assert.equal(doc.nestedXiIncludes.length, 0);
|
||||
|
||||
// Recorded offsets must slice the original text back out.
|
||||
const container = doc.assets[0];
|
||||
@@ -98,3 +99,24 @@ test("nested module payload does not create asset records", () => {
|
||||
["MESH"],
|
||||
);
|
||||
});
|
||||
|
||||
test("distinguishes root-level and nested xi:include", () => {
|
||||
const doc = scanXmlShallow(`<AssetDeclaration>
|
||||
<xi:include href="DATA:Top.xml"/>
|
||||
<W3DMesh id="MESH">
|
||||
<SubObject>
|
||||
<RenderObject>
|
||||
<xi:include href="DATA:Nested.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:X/child::*)"/>
|
||||
</RenderObject>
|
||||
</SubObject>
|
||||
</W3DMesh>
|
||||
</AssetDeclaration>`);
|
||||
assert.deepEqual(
|
||||
doc.rootXiIncludes.map((x) => x.href),
|
||||
["DATA:Top.xml"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
doc.nestedXiIncludes.map((x) => x.href),
|
||||
["DATA:Nested.xml"],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user