The three things core owes the client half before it can leave, all additive, all MODULE_API 1.2.0 → 1.3.0. `player.invite.accepted` is the third extension slot. Core's invite page owned a UO game-account step — it read a `gameAccountSignup` flag out of core's own settings and posted to a shard route — and an invite is a core concept that staff receive too, so the page stays and its optional next step becomes a slot. Named for the place, like the other two. Whether there is a step at all is the filling module's call, made from data core does not have; core keeps the shell, the skip control and the destination. `icon` on a nav item, because without it the six extracted UO rows would have been the only text-only entries in a sidebar where every other row has a glyph. Core supplies no fallback — an invented one is core making a presentation choice for content it knows nothing about. `icon` was already among the fields an override may not touch, so the concept predates a module being able to send one. `api.BASE` was in §3.5 from the first draft and never actually published. `request` is fetch-only, so an EventSource builds its own URL, and the shard's live feed is two of them; the alternative is a module hardcoding `/api/v1`, which asserts something about core that core has not promised. `AcceptInvite` is the one legitimate reader of `extensionFor` outside Slot.jsx: the answer decides a NAVIGATION, not a decoration. Decoration goes inside `<Slot wrap>`, which is why `hasExtension` stayed deleted. Co-Authored-By: Claude <noreply@anthropic.com>
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
import { test, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { withModuleNav } from '../src/modules/nav.js'
|
|
import { registerNav, _reset } from '../src/modules/registry.js'
|
|
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
|
|
|
|
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
|
|
// PR 8). Tested against the real merge next door rather than in isolation,
|
|
// because the property that matters is a relationship between the two: a module
|
|
// row has to be indistinguishable from a core row to everything downstream, and
|
|
// the way to prove that is to run the downstream thing on it.
|
|
|
|
const PUBLIC = [
|
|
{ label: 'Home', to: '/', end: true },
|
|
{ label: 'News', to: '/site/news' },
|
|
{ label: 'About', to: '/site/about' },
|
|
]
|
|
|
|
const ADMIN = [
|
|
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
|
|
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
|
|
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
|
|
{ items: [{ to: '/admin/account', label: 'Account' }] },
|
|
]
|
|
|
|
beforeEach(() => _reset())
|
|
|
|
test('with no module installed the base array is returned unchanged', () => {
|
|
// Identity, not a copy: this is what makes the useMemo in each layout honest,
|
|
// and what guarantees an instance with no modules renders what it renders now.
|
|
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
|
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
|
|
})
|
|
|
|
test('a flat nav places a module row by the order it asked for', () => {
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
|
|
assert.deepEqual(
|
|
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
|
['Home', 'Atlas', 'News', 'About'],
|
|
)
|
|
})
|
|
|
|
test('a flat row with no order appends rather than jumping to the front', () => {
|
|
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
|
|
// the one place a module could take over the nav without asking for anything.
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
|
assert.deepEqual(
|
|
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
|
['Home', 'News', 'About', 'Atlas'],
|
|
)
|
|
})
|
|
|
|
test('an explicit order beats a core row that merely sits at that index', () => {
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
|
|
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
|
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
|
|
})
|
|
|
|
test('an admin row lands INSIDE the core group it names', () => {
|
|
registerNav('uo', {
|
|
area: 'admin',
|
|
items: [
|
|
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
|
|
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
|
|
],
|
|
})
|
|
const nav = withModuleNav(ADMIN, 'admin')
|
|
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
|
|
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
|
|
// order 0 puts it above both core rows, which is the whole point of the field.
|
|
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
|
|
})
|
|
|
|
test('an unknown group appends a new group instead of dropping the row', () => {
|
|
// A typo must cost a position, never a link.
|
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
|
|
const nav = withModuleNav(ADMIN, 'admin')
|
|
assert.equal(nav.length, ADMIN.length + 1)
|
|
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
|
|
})
|
|
|
|
test('an admin row with no group gets a trailing untitled group of its own', () => {
|
|
// NOT folded into one of core's untitled groups: those are Dashboard at the
|
|
// top and Account at the bottom, and a module page belongs beside neither.
|
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
|
|
const nav = withModuleNav(ADMIN, 'admin')
|
|
assert.equal(nav.length, ADMIN.length + 1)
|
|
assert.equal(nav.at(-1).title, undefined)
|
|
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
|
|
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
|
|
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
|
|
})
|
|
|
|
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
|
|
// `to` is the key the override layer stores under and React renders by. Two
|
|
// rows sharing one would give an admin a single editor row that moves both.
|
|
const warnings = []
|
|
const warn = console.warn
|
|
console.warn = (msg) => warnings.push(msg)
|
|
try {
|
|
registerNav('uo', {
|
|
area: 'public',
|
|
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
|
|
})
|
|
const nav = withModuleNav(PUBLIC, 'public')
|
|
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
|
|
assert.equal(warnings.length, 1)
|
|
assert.match(warnings[0], /\/site\/news.*collides/)
|
|
} finally {
|
|
console.warn = warn
|
|
}
|
|
})
|
|
|
|
test('two modules cannot claim the same path either', () => {
|
|
const warn = console.warn
|
|
console.warn = () => {}
|
|
try {
|
|
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
|
|
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
|
|
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
|
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
|
|
} finally {
|
|
console.warn = warn
|
|
}
|
|
})
|
|
|
|
test('a module row carries its moduleId through, which is how the gate finds it', () => {
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
|
|
const row = withModuleNav(PUBLIC, 'public').at(-1)
|
|
assert.equal(row.moduleId, 'uo')
|
|
assert.equal(row.feature, 'atlas')
|
|
})
|
|
|
|
test('a module row carries its icon through, and an override cannot touch it', () => {
|
|
// 1.3.0. Without an `icon` the six extracted UO rows would have been the only
|
|
// text-only entries in a sidebar where every other row has a glyph. Core
|
|
// renders whatever component the row carries and supplies no fallback — an
|
|
// invented one would be core making a presentation choice for content it knows
|
|
// nothing about.
|
|
const Glyph = () => null
|
|
registerNav('uo', {
|
|
area: 'admin',
|
|
items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }],
|
|
})
|
|
const merged = withModuleNav(ADMIN, 'admin')
|
|
const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
|
assert.equal(row.icon, Glyph)
|
|
|
|
// `icon` was already on navOverrides' list of fields an override may not
|
|
// touch, from long before a module could supply one. It still is.
|
|
const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } })
|
|
const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
|
|
assert.equal(after.label, 'Renamed')
|
|
assert.equal(after.icon, Glyph)
|
|
})
|
|
|
|
test('a row with no icon simply has none, the same as a core row with none', () => {
|
|
registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] })
|
|
const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0]
|
|
assert.equal(row.to, '/player/uo/characters')
|
|
assert.equal(row.icon, undefined)
|
|
})
|
|
|
|
test('areas do not leak into one another', () => {
|
|
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
|
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
|
})
|
|
|
|
// ── The relationship that is the actual requirement ───────────────────────
|
|
|
|
test('an admin override applies to a module row exactly as to a core row', () => {
|
|
// The reason the interleave happens BEFORE the merge and not after: the merge
|
|
// drops any key its base array does not declare, so appending module rows
|
|
// afterwards would make every one of them unorderable, unrelabellable and
|
|
// unhideable — a visible regression the day the UO rows leave core.
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
|
const base = withModuleNav(PUBLIC, 'public')
|
|
const merged = applyNavOverrides(base, {
|
|
'/uo/atlas': { label: 'Bestiary', order: 0 },
|
|
'/site/news': { order: 3 },
|
|
})
|
|
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
|
|
})
|
|
|
|
test('an override can hide a module row, and the public tree can section it', () => {
|
|
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
|
|
const base = withModuleNav(PUBLIC, 'public')
|
|
|
|
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
|
|
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
|
|
|
|
const sectioned = buildPublicNav(base, {
|
|
items: { '/uo/market': { section: 'sec_shard' } },
|
|
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
|
|
})
|
|
assert.equal(sectioned[0].kind, 'section')
|
|
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
|
|
})
|
|
|
|
test('a module row can be moved between admin groups by an override', () => {
|
|
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
|
const base = withModuleNav(ADMIN, 'admin')
|
|
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
|
|
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
|
|
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
|
|
})
|
|
|
|
test('a group a module created is itself a legal override destination', () => {
|
|
// Falls out of building the destination set from the base nav it is handed —
|
|
// recorded because it is the kind of thing that would otherwise be discovered
|
|
// by an admin finding a section they cannot move anything into.
|
|
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
|
|
const base = withModuleNav(ADMIN, 'admin')
|
|
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
|
|
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
|
|
})
|