`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and `uo.decor.place`, over protocol 7's one command family. Five actions because five is what an author has; one `perform`/`revert`/`reconcile` because on the wire they are one thing. Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`, `uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead, 2026-09-07). Core meters whatever dimensions a module declares and holds no UO knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate is priced in minutes rather than in gates: one standing all day and twelve standing five minutes each are not the same imposition on a world. `reconcile()` ASKS the shard, and is the one place in this file that must not use `reconcileByBootId`. A crier line lives in shard memory, so a changed `bootId` IS proof it is gone; a spawned creature is in the world SAVE and survives the restart the stamp would report it lost by. Anything `world.owned` does not list is gone -- safe only because the shard's registry and the objects it describes are written by the same save. Teardown reports `gone` as success and `refused` as failed. A creature a player killed is the point of having spawned it, and a run that ended `incomplete` because its event worked would be a report nobody could read. `refused` means the shard denies this run ever owned the serial, so nothing will delete it through this path and the row must land unresolved with a reason. The atlas gains a decoration index, parsed from the shard's own `Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree nests two deep and a flat read would index a fraction of it while looking like it worked. 313 distinct types. The decor verb resolves through it rather than passing a type name through, which keeps the verb to this shard's own decoration vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements under 1992 different graphics, so a bare type name places the wrong thing. `PARSER_VERSION` -> 3, so an already-imported tree is re-read. Two things the build found in code that had already shipped: `uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not something the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is not one. The atlas's `name` is the raw type token from the spawn files, so the fix was to stop discarding the half that works. Safe to change because Phase 12a is the source's first consumer; the file said so when it shipped. `uo.npc.place` could not be performed from its own required params. Both ends refuse an oracle with neither a greeting nor a line, but both fields were optional -- so a cross-field rule sat where no authoring form could render it. The greeting is now `required`, which says the same thing in the contract itself. Caught by the existing dry-run sweep, which is a better argument for that test than anything written about it when it shipped. 605 tests pass. `swagger-fragment.json` is stale on `edge` already and this phase adds no route, so it is left alone. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
457 lines
18 KiB
JavaScript
457 lines
18 KiB
JavaScript
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||
// every moved test: core internals can no longer be stubbed by requiring them,
|
||
// because there are none to require — `../utils/db` and `../model/settings` do
|
||
// not exist here. What a test controls instead is the `ctx` core would have
|
||
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||
// contract actually promises.
|
||
|
||
const fs = require('fs')
|
||
const os = require('os')
|
||
const path = require('path')
|
||
|
||
const { test, after, beforeEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const {
|
||
AtlasSourceError,
|
||
aggregateCreatures,
|
||
displayName,
|
||
sameSources,
|
||
hashSources,
|
||
buildAtlas,
|
||
PARSER_VERSION,
|
||
} = require('../utils/spawnAtlasSource')
|
||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||
const { ctx } = require('./_setup')
|
||
const settings = ctx.settings
|
||
|
||
|
||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||
//
|
||
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||
// outright, or rename them when its maps are updated, and everything has to keep
|
||
// working with no code change.
|
||
|
||
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||
|
||
// Decoration, NESTED, because the real tree nests two deep in places
|
||
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
|
||
// fraction of it while looking like it worked.
|
||
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Decoration', 'top.cfg'),
|
||
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
|
||
'utf8',
|
||
)
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
|
||
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
|
||
'utf8',
|
||
)
|
||
|
||
for (const facet of facets) {
|
||
fs.writeFileSync(
|
||
path.join(root, 'Spawns', `${facet}.xml`),
|
||
`<Spawns>
|
||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||
<Objects2>Ghost:MX=1</Objects2></Points>
|
||
</Spawns>`,
|
||
'utf8',
|
||
)
|
||
// The location file names its facet differently from <Map>, the real
|
||
// `Ter Mur` / `Tokuno Islands` drift.
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||
'utf8',
|
||
)
|
||
}
|
||
|
||
fs.writeFileSync(
|
||
path.join(root, 'Data', 'Regions.xml'),
|
||
`<ServerRegions>${facets
|
||
.map(
|
||
(facet) => `<Facet name="${facet}">
|
||
<region type="TownRegion" priority="10" name="${facet} City">
|
||
<rect x="1000" y="1000" width="500" height="500" />
|
||
</region></Facet>`,
|
||
)
|
||
.join('')}</ServerRegions>`,
|
||
'utf8',
|
||
)
|
||
|
||
if (includeChampions) {
|
||
fs.writeFileSync(
|
||
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||
</spawn></championSystem>`,
|
||
'utf8',
|
||
)
|
||
}
|
||
}
|
||
|
||
function tempTree(options) {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||
writeTree(root, options)
|
||
return root
|
||
}
|
||
|
||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||
|
||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||
assert.equal(atlas.meta.counts.facets, 2)
|
||
})
|
||
|
||
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||
// And the fallback actually fires, rather than the point reading Wilderness.
|
||
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||
assert.equal(far.landmark, 'Deep Cave')
|
||
assert.equal(far.label, 'Deep Cave')
|
||
})
|
||
|
||
test('buildAtlas: resolves a contained point to its region', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||
assert.equal(inCity.region, 'Sosaria City')
|
||
assert.equal(inCity.label, 'Sosaria City')
|
||
})
|
||
|
||
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||
})
|
||
|
||
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||
})
|
||
|
||
test('buildAtlas: a tree with no champion file still builds', () => {
|
||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||
assert.deepEqual(atlas.champions, [])
|
||
})
|
||
|
||
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||
assert.throws(
|
||
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||
)
|
||
})
|
||
|
||
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||
})
|
||
|
||
// ── Hashing ────────────────────────────────────────────────────────────────
|
||
|
||
test('hashSources: stable across reads, changes when a file changes', () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const first = hashSources(root)
|
||
assert.ok(sameSources(first, hashSources(root)))
|
||
|
||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||
assert.equal(sameSources(first, hashSources(root)), false)
|
||
})
|
||
|
||
test('sameSources: a missing or extra file is a difference', () => {
|
||
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||
assert.equal(sameSources(null, { a: '1' }), false)
|
||
assert.equal(sameSources({ a: '1' }, null), false)
|
||
})
|
||
|
||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||
|
||
const POINTS = [
|
||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||
]
|
||
|
||
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||
assert.equal(lizardman.total, 10)
|
||
assert.equal(lizardman.points, 3)
|
||
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||
})
|
||
|
||
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||
const creatures = aggregateCreatures(POINTS)
|
||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||
})
|
||
|
||
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||
// but a spurious diff in the DB on every restart would be just as wrong.
|
||
assert.equal(
|
||
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||
)
|
||
})
|
||
|
||
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||
// insert and take the whole transaction with it.
|
||
const rows = shardAtlas.pointTypeRows([
|
||
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||
])
|
||
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||
})
|
||
|
||
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||
const rows = shardAtlas.pointTypeRows([
|
||
{ types: [{ type: 'A', max: 1 }] },
|
||
{ types: [{ type: 'B', max: 1 }] },
|
||
])
|
||
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||
})
|
||
|
||
// ── The refresh decision ───────────────────────────────────────────────────
|
||
//
|
||
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||
// facet removal on its own.
|
||
|
||
let applied
|
||
let pendingRow
|
||
let facetsInDb
|
||
let metaRow
|
||
|
||
beforeEach(() => {
|
||
applied = null
|
||
pendingRow = null
|
||
facetsInDb = []
|
||
metaRow = null
|
||
atlasDb.replaceAtlas = async (atlas) => {
|
||
applied = atlas
|
||
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||
}
|
||
atlasDb.getMeta = async () => metaRow
|
||
atlasDb.getFacets = async () => facetsInDb
|
||
atlasDb.getPending = async () => pendingRow
|
||
atlasDb.setPending = async (payload, status) => {
|
||
pendingRow = { ...payload, status }
|
||
}
|
||
atlasDb.clearPending = async () => {
|
||
pendingRow = null
|
||
}
|
||
settings.get = async () => ''
|
||
process.env.SERVUO_PATH = ''
|
||
})
|
||
|
||
test('refresh: no configured path is skipped, not an error', async () => {
|
||
const result = await shardAtlas.refresh()
|
||
assert.equal(result.status, 'skipped')
|
||
})
|
||
|
||
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||
assert.equal(result.status, 'unavailable')
|
||
assert.equal(result.code, 'NOT_FOUND')
|
||
})
|
||
|
||
test('refresh: a fresh database imports', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||
})
|
||
|
||
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = buildAtlas(root).meta
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'unchanged')
|
||
assert.equal(applied, null)
|
||
})
|
||
|
||
// The hash gate alone would strand an install whose maps never change on
|
||
// whatever an older build derived: a corrected parse would ship and never reach
|
||
// the data, because the only thing compared is the tree.
|
||
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
})
|
||
|
||
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
const meta = buildAtlas(root).meta
|
||
delete meta.parserVersion
|
||
metaRow = meta
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
})
|
||
|
||
test('refresh: --force reimports an unchanged tree', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
metaRow = buildAtlas(root).meta
|
||
const result = await shardAtlas.refresh({ path: root, force: true })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
})
|
||
|
||
test('refresh: a NEW facet applies straight away', async () => {
|
||
// Additions cannot destroy anything an operator would miss.
|
||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||
facetsInDb = ['Sosaria']
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||
})
|
||
|
||
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
const result = await shardAtlas.refresh({ path: root })
|
||
assert.equal(result.status, 'needsReview')
|
||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||
// The critical part: the existing atlas was left alone.
|
||
assert.equal(applied, null)
|
||
assert.equal(pendingRow.status, 'pending')
|
||
})
|
||
|
||
test('refresh: approving applies the removal', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
assert.equal(applied, null)
|
||
|
||
const result = await shardAtlas.approvePending({ path: root })
|
||
assert.equal(result.status, 'imported')
|
||
assert.ok(applied)
|
||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||
})
|
||
|
||
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
await shardAtlas.rejectPending()
|
||
assert.equal(pendingRow.status, 'rejected')
|
||
|
||
const again = await shardAtlas.refresh({ path: root })
|
||
assert.equal(again.status, 'unchanged')
|
||
assert.equal(applied, null)
|
||
})
|
||
|
||
test('refresh: changing the tree asks again after a rejection', async () => {
|
||
const root = tempTree({ facets: ['Sosaria'] })
|
||
facetsInDb = ['Sosaria', 'Underdark']
|
||
await shardAtlas.refresh({ path: root })
|
||
await shardAtlas.rejectPending()
|
||
|
||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||
const again = await shardAtlas.refresh({ path: root })
|
||
assert.equal(again.status, 'needsReview')
|
||
})
|
||
|
||
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||
atlasDb.getMeta = async () => {
|
||
throw new Error('database is on fire')
|
||
}
|
||
atlasDb.getFacets = async () => {
|
||
throw new Error('still on fire')
|
||
}
|
||
atlasDb.replaceAtlas = async () => {
|
||
throw new Error('and the import too')
|
||
}
|
||
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||
|
||
const result = await shardAtlas.refreshOnBoot()
|
||
assert.equal(result.status, 'failed')
|
||
})
|
||
|
||
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||
const result = await shardAtlas.refreshOnBoot()
|
||
assert.equal(result.status, 'unavailable')
|
||
})
|
||
|
||
test('refresh: an explicit path overrides the configured one', async () => {
|
||
const configured = tempTree({ facets: ['Configured'] })
|
||
const override = tempTree({ facets: ['Override'] })
|
||
settings.get = async () => configured
|
||
|
||
const result = await shardAtlas.refresh({ path: override })
|
||
assert.equal(result.status, 'imported')
|
||
assert.deepEqual(result.addedFacets, ['Override'])
|
||
})
|
||
|
||
// ── The decoration index (Phase 12a) ────────────────────────
|
||
|
||
test('decoration is read recursively and rolled up per type', () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
|
||
try {
|
||
writeTree(root)
|
||
const atlas = buildAtlas(root)
|
||
|
||
// Sorted by type, and `uses` counts every header line across the whole tree
|
||
// — the nested file's Brazier is the second use of the same type, not a
|
||
// second type.
|
||
assert.deepEqual(atlas.decor, [
|
||
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
|
||
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
|
||
{ type: 'Static', itemId: 0x108f, uses: 1 },
|
||
])
|
||
assert.equal(atlas.meta.counts.decor, 3)
|
||
|
||
// Every decoration file is fingerprinted like every other source, so an
|
||
// operator editing one is a tree change the boot path notices.
|
||
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
|
||
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('a tree with no decoration at all still builds', () => {
|
||
// Optional, like the champion file. A shard that has stripped its decoration
|
||
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
|
||
try {
|
||
writeTree(root)
|
||
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
|
||
const atlas = buildAtlas(root)
|
||
assert.deepEqual(atlas.decor, [])
|
||
assert.equal(atlas.meta.counts.decor, 0)
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|