feat(cliloc): import the table from the shard, not from a file someone converted (Phase 2)
The base cliloc table now comes over the bridge. `clilocBridge.js` walks
`GET /cliloc` page by page and the model merges the `custom/` overlays over it —
overlays stay on disk because ServUO has no server-side notion of a custom
cliloc, so there is nothing on the shard to ask for.
**The shard wins whenever uo-link is configured and enabled**, with no mode
setting: there is no version of "which source?" an operator benefits from
answering. A file on disk remains the source only where there is no shard link,
plus a one-off explicit `path` — deprecated, not removed, and unchanged.
**Boot no longer imports on the bridge.** The file path could hash 5 MB locally
and skip in 14 ms; a shard round trip in the boot sequence would be spent
answering "no" on every restart but the one after a client patch — and patching a
client is an operator action, so importing became one. Admin → Shard → Import.
Whatever table is loaded keeps serving until then.
Three checks in the walk, each for a way a shard can hand back a table that looks
complete:
* only `cut: 'end'` finishes it — a short page can equally be a spent budget,
and a truncated table renders some items named and some not, which is exactly
what NO table looks like;
* the cursor must advance, or the walk stops rather than spinning;
* every page echoes the source's size and mtime, so a client patched mid-import
is refused outright rather than stitched from two files.
**The base is exempt from the vanished-source rule**, which is an upgrade detail
rather than a preference: an install that used the file pipeline carries its base
file's label in the stored fingerprint, and on the bridge that label is *supposed*
to disappear. Counting it as vanished would demand an approval for a change the
upgrade itself made. Overlays keep the rule in full.
**The protocol pin moves 7 → 8** — the third declaration site, and the one
nothing enforces. Phase 1 moved the sidecar and the overlay together because the
installer refuses a mismatched bundle; this one has to be moved by hand, in the
phase that first calls a protocol-8 route. The schema block above it is the
record of what forgetting costs: two phases of every REST call answered 409.
Verified against a live shard, sidecar and site: 12 pages, 67,496 rows imported
in 1.68 s, the operator's three-row overlay overriding stock strings on top of
it, and the next import correctly `unchanged`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
303
server/test/clilocBridge.test.js
Normal file
303
server/test/clilocBridge.test.js
Normal file
@@ -0,0 +1,303 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const bridge = require('../utils/clilocBridge')
|
||||
|
||||
// The walk over `GET /cliloc`, driven against a stubbed sidecar client.
|
||||
//
|
||||
// Everything asserted here is a way the shard can be wrong that leaves the
|
||||
// website holding a table it believes is complete. That is the failure worth
|
||||
// testing, because it is invisible downstream: a truncated cliloc table renders
|
||||
// some items with names and some with ids, which is exactly what NO table looks
|
||||
// like. None of these are hypothetical shapes — each corresponds to a field the
|
||||
// paging envelope carries specifically so this side can tell the difference
|
||||
// (docs/link/v8.md §3.4).
|
||||
|
||||
const saved = {}
|
||||
|
||||
function stub({ sources, pages }) {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
|
||||
const calls = []
|
||||
|
||||
uoLinkClient.getAssetSources = async () => sources
|
||||
uoLinkClient.getClilocTable = async ({ lang, cursor } = {}) => {
|
||||
calls.push({ lang, cursor: cursor ?? null })
|
||||
const next = pages.shift()
|
||||
if (!next) throw new Error('the walk asked for more pages than the test supplied')
|
||||
return next
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (saved.getAssetSources) uoLinkClient.getAssetSources = saved.getAssetSources
|
||||
if (saved.getClilocTable) uoLinkClient.getClilocTable = saved.getClilocTable
|
||||
}
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 200, data })
|
||||
|
||||
/** One page of rows, with the source fingerprint every page echoes. */
|
||||
const page = (rows, extra = {}) =>
|
||||
ok({
|
||||
kind: 'cliloc.table.ok',
|
||||
lang: 'enu',
|
||||
file: 'cliloc.enu',
|
||||
size: 4989921,
|
||||
mtime: 1757462400000,
|
||||
total: 3,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...extra,
|
||||
})
|
||||
|
||||
const sourcesReply = (file = {}) =>
|
||||
ok({
|
||||
kind: 'assets.sources.ok',
|
||||
extractorVersion: 1,
|
||||
imaging: { ok: true },
|
||||
hashing: false,
|
||||
complete: true,
|
||||
files: [
|
||||
{ name: 'cliloc.enu', path: '/uo/cliloc.enu', size: 4989921, mtime: 1757462400000, sha256: 'abc', ...file },
|
||||
{ name: 'art.mul', path: '/uo/art.mul', size: 148000000, mtime: 1, sha256: null },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Stage 1: the fingerprint ───────────────────────────────────────────────
|
||||
|
||||
test('fingerprint picks the cliloc file out of the client manifest', async (t) => {
|
||||
stub({ sources: sourcesReply(), pages: [] })
|
||||
t.after(restore)
|
||||
|
||||
const fp = await bridge.fingerprint()
|
||||
|
||||
assert.equal(fp.file, 'cliloc.enu')
|
||||
assert.equal(fp.size, 4989921)
|
||||
assert.equal(fp.sha256, 'abc')
|
||||
assert.equal(fp.extractorVersion, 1)
|
||||
})
|
||||
|
||||
test('a client with no cliloc file is NO_SOURCE, not a crash', async (t) => {
|
||||
stub({
|
||||
sources: ok({ extractorVersion: 1, files: [{ name: 'art.mul', size: 1, mtime: 1 }] }),
|
||||
pages: [],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.fingerprint(), (err) => {
|
||||
assert.equal(err.code, 'NO_SOURCE')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('the asset plane being switched off reads as a refusal, not a bug', async (t) => {
|
||||
stub({
|
||||
sources: { ok: false, status: 403, data: { reason: 'asset extraction is disabled on this shard' } },
|
||||
pages: [],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.fingerprint(), (err) => {
|
||||
assert.equal(err.code, 'DISABLED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// A hash that has not been computed yet is the shard's ordinary first answer:
|
||||
// hashing the 343 MB of art and animation it also serves cannot fit in a 10 s
|
||||
// reply, so it happens off the request path. Treating a null hash as a CHANGE
|
||||
// would make the panel show drift forever on a shard nobody has imported from.
|
||||
test('a missing hash falls back to (size, mtime) rather than reading as drift', () => {
|
||||
const before = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
|
||||
const after = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(before, after), true)
|
||||
assert.equal(bridge.sameSource(before, { ...after, mtime: 21 }), false)
|
||||
})
|
||||
|
||||
test('a hash on both sides beats size and mtime, which a patched-in-place file can preserve', () => {
|
||||
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(a, { ...a, sha256: 'bbb' }), false)
|
||||
assert.equal(bridge.sameSource(a, { ...a, size: 11, mtime: 99 }), true)
|
||||
})
|
||||
|
||||
test('the extractor version is part of the fingerprint, so a corrected reader drifts', () => {
|
||||
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(a, { ...a, extractorVersion: 2 }), false)
|
||||
})
|
||||
|
||||
// ── Stage 2: the walk ──────────────────────────────────────────────────────
|
||||
|
||||
test('a one-page table comes back whole', async (t) => {
|
||||
const calls = stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 3, f: 0, t: 'c' }, { n: 1, f: 2, t: 'a' }])],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries, source } = await bridge.readCliloc()
|
||||
|
||||
assert.deepEqual(entries, [
|
||||
{ number: 3, flag: 0, text: 'c' },
|
||||
{ number: 1, flag: 2, text: 'a' },
|
||||
])
|
||||
assert.equal(source.pages, 1)
|
||||
assert.equal(source.received, 2)
|
||||
assert.equal(source.reported, 3)
|
||||
assert.deepEqual(calls, [{ lang: 'enu', cursor: null }])
|
||||
})
|
||||
|
||||
test('pages are walked by echoing the cursor back until more is false', async (t) => {
|
||||
const calls = stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:2', cut: 'budget' }),
|
||||
page([{ n: 3, f: 0, t: 'c' }]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries, source } = await bridge.readCliloc()
|
||||
|
||||
assert.equal(entries.length, 3)
|
||||
assert.equal(source.pages, 3)
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.cursor),
|
||||
[null, 'n:1', 'n:2'],
|
||||
)
|
||||
})
|
||||
|
||||
// `cut` is the field that is easy to omit and expensive not to have. A short
|
||||
// page means the source ended, the byte budget was spent, or the family hit its
|
||||
// own limit — and only the first means finished.
|
||||
test('a last page that did not end the table is refused, not imported', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: false, cut: 'limit' })],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'INCOMPLETE')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('a shard that does not advance its cursor is stopped rather than spun on', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'STUCK')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('more:true with no cursor at all is the same refusal', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: true, cut: 'budget' })],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'STUCK')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// The one failure a count cannot catch: an operator patches their client while
|
||||
// the import is walking it. Half of what arrived is from a file that no longer
|
||||
// exists, and nothing later can tell which half.
|
||||
test('a client patched mid-walk aborts the whole import', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { size: 5000000, mtime: 1757470000000 }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'SOURCE_CHANGED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 425 is flow control and the ORDINARY answer during an import — the shard's
|
||||
// asset plane serves one request at a time on purpose — so it is retried rather
|
||||
// than failed. (The backoff is real time, so this exercises one retry only.)
|
||||
test('a busy shard is retried, because the work is happening', async (t) => {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
t.after(restore)
|
||||
|
||||
let attempts = 0
|
||||
uoLinkClient.getAssetSources = async () => sourcesReply()
|
||||
uoLinkClient.getClilocTable = async () => {
|
||||
attempts++
|
||||
if (attempts === 1) return { ok: false, status: 425, data: { kind: 'bridge.busy' } }
|
||||
return page([{ n: 1, f: 0, t: 'a' }])
|
||||
}
|
||||
|
||||
const { entries } = await bridge.readCliloc()
|
||||
|
||||
assert.equal(attempts, 2)
|
||||
assert.equal(entries.length, 1)
|
||||
})
|
||||
|
||||
test('a page with no rows array is malformed, not an empty table', async (t) => {
|
||||
stub({ sources: sourcesReply(), pages: [ok({ kind: 'cliloc.table.ok', more: false, cut: 'end' })] })
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'MALFORMED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('a shard that never ends the table is bounded by the page cap', async (t) => {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
t.after(restore)
|
||||
|
||||
let n = 0
|
||||
uoLinkClient.getAssetSources = async () => sourcesReply()
|
||||
uoLinkClient.getClilocTable = async () => {
|
||||
n++
|
||||
return page([{ n, f: 0, t: 'x' }], { more: true, cursor: `n:${n}`, cut: 'budget' })
|
||||
}
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'TOO_LARGE')
|
||||
return true
|
||||
})
|
||||
assert.equal(n, bridge.MAX_PAGES)
|
||||
})
|
||||
|
||||
test('rows with an unusable id are dropped rather than stored as NaN', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 'nonsense', f: 0, t: 'a' }, { n: 7, f: 0, t: 'b' }])],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries } = await bridge.readCliloc()
|
||||
|
||||
assert.deepEqual(entries, [{ number: 7, flag: 0, text: 'b' }])
|
||||
})
|
||||
330
server/test/clilocSourceSelection.test.js
Normal file
330
server/test/clilocSourceSelection.test.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// Which cliloc source runs, and what the shard path does with the answer
|
||||
// (docs/link/v8.md §9, docs/website/CLILOCS.md).
|
||||
//
|
||||
// The model is the only place that decides between the two pipelines, so these
|
||||
// drive it with the shard, the database and the filesystem all stubbed. Nothing
|
||||
// here reaches the real sidecar or a real table.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||||
const db = require('../model/shardClilocs/shardClilocs.db')
|
||||
const bridge = require('../utils/clilocBridge')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { ctx } = require('./_setup')
|
||||
|
||||
const saved = {
|
||||
getMeta: db.getMeta,
|
||||
replaceAll: db.replaceAll,
|
||||
count: db.count,
|
||||
fingerprint: bridge.fingerprint,
|
||||
readCliloc: bridge.readCliloc,
|
||||
getSafe: uoLinkConfig.getSafe,
|
||||
settingsGet: ctx.settings.get,
|
||||
}
|
||||
|
||||
function restore() {
|
||||
db.getMeta = saved.getMeta
|
||||
db.replaceAll = saved.replaceAll
|
||||
db.count = saved.count
|
||||
bridge.fingerprint = saved.fingerprint
|
||||
bridge.readCliloc = saved.readCliloc
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
ctx.settings.get = saved.settingsGet
|
||||
}
|
||||
|
||||
const FINGERPRINT = {
|
||||
kind: 'bridge',
|
||||
file: 'cliloc.enu',
|
||||
size: 4989921,
|
||||
mtime: 1757462400000,
|
||||
sha256: 'abc',
|
||||
extractorVersion: 1,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* A rig with the shard reachable (or not), the configured overlay path pointed
|
||||
* at a temp directory, and every write captured rather than made.
|
||||
*/
|
||||
function rig({ linked = true, meta = null, clientPath = '', rows = [] } = {}) {
|
||||
const applied = []
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: linked, baseUrl: linked ? 'http://127.0.0.1:8099' : null })
|
||||
ctx.settings.get = async (key) => (key === clilocs.SETTING_KEY ? clientPath : null)
|
||||
|
||||
db.getMeta = async () => meta
|
||||
db.count = async () => meta?.count ?? 0
|
||||
db.replaceAll = async (entries, writtenMeta) => {
|
||||
applied.push({ entries, meta: writtenMeta })
|
||||
return { count: entries.length, blank: 0, duplicates: 0 }
|
||||
}
|
||||
|
||||
bridge.fingerprint = async () => FINGERPRINT
|
||||
bridge.readCliloc = async () => ({
|
||||
entries: rows,
|
||||
source: {
|
||||
kind: 'bridge',
|
||||
lang: 'enu',
|
||||
file: 'cliloc.enu',
|
||||
size: FINGERPRINT.size,
|
||||
mtime: FINGERPRINT.mtime,
|
||||
pages: 1,
|
||||
reported: rows.length,
|
||||
received: rows.length,
|
||||
},
|
||||
})
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
function tmpWithOverlay(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-sel-'))
|
||||
fs.mkdirSync(path.join(dir, 'custom'), { recursive: true })
|
||||
if (contents !== undefined) fs.writeFileSync(path.join(dir, 'custom', 'shard.tsv'), contents)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── Which source runs ──────────────────────────────────────────────────────
|
||||
|
||||
test('a configured shard is the base source, and the file path is not consulted', async (t) => {
|
||||
const applied = rig({ rows: [{ number: 1, flag: 0, text: 'a' }] })
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].meta.source, 'bridge')
|
||||
})
|
||||
|
||||
test('no shard link falls back to the file pipeline, unchanged', async (t) => {
|
||||
rig({ linked: false })
|
||||
t.after(restore)
|
||||
|
||||
// No path configured either, so the file path reports exactly what it always
|
||||
// did — which is the assertion: the fallback is the OLD code, not a new one.
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.reason, 'no cliloc path configured')
|
||||
})
|
||||
|
||||
test('an explicit path is still an escape hatch, even with a shard linked', async (t) => {
|
||||
let asked = false
|
||||
rig({ linked: true })
|
||||
bridge.fingerprint = async () => {
|
||||
asked = true
|
||||
return FINGERPRINT
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh({ path: path.join(os.tmpdir(), 'nope-does-not-exist') })
|
||||
|
||||
assert.equal(asked, false, 'the shard must not be asked when a file was named')
|
||||
assert.equal(result.status, 'unavailable')
|
||||
})
|
||||
|
||||
// Boot deliberately does not call the shard: it would put a sidecar round trip
|
||||
// in the startup sequence to answer a question whose answer is "no" except after
|
||||
// a client patch, which is an operator action.
|
||||
test('boot imports nothing over the bridge and leaves the loaded table serving', async (t) => {
|
||||
let asked = false
|
||||
rig({ linked: true })
|
||||
bridge.fingerprint = async () => {
|
||||
asked = true
|
||||
return FINGERPRINT
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refreshOnBoot()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(asked, false)
|
||||
})
|
||||
|
||||
// ── The gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('an unchanged client file and no overlays is a no-op', async (t) => {
|
||||
const applied = rig({
|
||||
meta: { source: 'bridge', base: FINGERPRINT, hashes: {}, parserVersion: 1, count: 67496 },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'unchanged')
|
||||
assert.equal(result.count, 67496)
|
||||
assert.equal(applied.length, 0)
|
||||
})
|
||||
|
||||
test('a patched client re-imports', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'bridge',
|
||||
base: { ...FINGERPRINT, sha256: 'older' },
|
||||
hashes: {},
|
||||
parserVersion: 1,
|
||||
count: 10,
|
||||
},
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
assert.equal((await clilocs.refresh()).status, 'imported')
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// The upgrade path. An install that used the converted-file pipeline carries its
|
||||
// base label in the stored fingerprint; on the bridge that label is SUPPOSED to
|
||||
// disappear. Counting it as a vanished source would make the first import after
|
||||
// the upgrade demand an approval for a change the upgrade itself made.
|
||||
test('the retired file base is not reported as a vanished source', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'file',
|
||||
hashes: { 'clilocs.plain': 'aaa' },
|
||||
parserVersion: 1,
|
||||
count: 67496,
|
||||
},
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported', result.reason)
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// An overlay is a different matter: it vanished, and an unmounted volume looks
|
||||
// exactly like a deliberate deletion from here.
|
||||
test('a vanished OVERLAY still stages for review', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'bridge',
|
||||
base: FINGERPRINT,
|
||||
hashes: { 'custom/shard.tsv': 'aaa' },
|
||||
parserVersion: 1,
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.missingSources, ['custom/shard.tsv'])
|
||||
assert.equal(applied.length, 0)
|
||||
|
||||
const accepted = await clilocs.refresh({ approve: true })
|
||||
assert.equal(accepted.status, 'imported')
|
||||
assert.deepEqual(accepted.acceptedMissing, ['custom/shard.tsv'])
|
||||
})
|
||||
|
||||
// ── The merge ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('an overlay overrides the shard table, and says so', async (t) => {
|
||||
const dir = tmpWithOverlay('1023721\ta better staff\n900001\ta shard-only item\n')
|
||||
const applied = rig({
|
||||
clientPath: dir,
|
||||
rows: [
|
||||
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
||||
{ number: 3000001, flag: 0, text: 'Entering Britannia...' },
|
||||
],
|
||||
})
|
||||
t.after(() => {
|
||||
restore()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported', result.reason)
|
||||
|
||||
const stored = new Map(applied[0].entries.map((e) => [e.number, e.text]))
|
||||
assert.equal(stored.get(1023721), 'a better staff', 'the overlay must win')
|
||||
assert.equal(stored.get(3000001), 'Entering Britannia...')
|
||||
assert.equal(stored.get(900001), 'a shard-only item')
|
||||
|
||||
const overlay = result.sources.find((s) => s.kind === 'custom')
|
||||
assert.equal(overlay.label, 'custom/shard.tsv')
|
||||
assert.equal(overlay.added, 1)
|
||||
assert.equal(overlay.overrode, 1)
|
||||
|
||||
// Only overlay hashes are stored now — the base is fingerprinted separately,
|
||||
// and mixing them is what made the upgrade case above ambiguous.
|
||||
assert.deepEqual(Object.keys(applied[0].meta.hashes), ['custom/shard.tsv'])
|
||||
assert.equal(applied[0].meta.base.sha256, 'abc')
|
||||
})
|
||||
|
||||
test('a malformed overlay names the file rather than failing the import namelessly', async (t) => {
|
||||
const dir = tmpWithOverlay('not a cliloc file at all\n')
|
||||
rig({ clientPath: dir, rows: [{ number: 1, flag: 0, text: 'a' }] })
|
||||
t.after(() => {
|
||||
restore()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.match(result.reason, /custom\/shard\.tsv/)
|
||||
})
|
||||
|
||||
// An overlay path an operator has mistyped must not stop a base table that
|
||||
// arrived perfectly well — but it must be visible, or the site silently serves a
|
||||
// table missing every shard-added name.
|
||||
test('an unreadable overlay path is reported beside a successful import', async (t) => {
|
||||
const applied = rig({
|
||||
clientPath: path.join(os.tmpdir(), 'cliloc-does-not-exist-at-all'),
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.match(result.overlayProblem, /does not exist/)
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('status describes the shard source, hash state and drift', async (t) => {
|
||||
rig({ meta: { source: 'bridge', base: FINGERPRINT, hashes: {}, parserVersion: 1, count: 67496 } })
|
||||
t.after(restore)
|
||||
|
||||
const status = await clilocs.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.file, 'cliloc.enu')
|
||||
assert.equal(status.fileReadable, true)
|
||||
assert.equal(status.drift, false)
|
||||
assert.equal(status.shard.extractorVersion, 1)
|
||||
assert.equal(status.shard.hashing, false)
|
||||
})
|
||||
|
||||
test('a shard that cannot be reached is a problem on the status, not a throw', async (t) => {
|
||||
rig({})
|
||||
bridge.fingerprint = async () => {
|
||||
throw new bridge.ClilocBridgeError('The shard did not answer: timeout', 'SHARD_DOWN')
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const status = await clilocs.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.fileReadable, false)
|
||||
assert.equal(status.code, 'SHARD_DOWN')
|
||||
// Null, not false: with no fingerprint there is nothing to compare, and
|
||||
// reporting "no drift" would read as "up to date".
|
||||
assert.equal(status.drift, null)
|
||||
})
|
||||
Reference in New Issue
Block a user