The inverted slot direction reached exactly one module. Core filled three
literal names - uo.guild.detail, uo.guild.forum, uo.guild.header - matched by
exact name in applyCoreFills, so a second game declaring a place under its own
id got an empty page and no error. "A fill for a slot nobody declared is not an
error" is the rule that made the miss invisible, and it is the right rule; what
was wrong was core knowing a slot's name at all.
It also put a module identifier inside core, in three string literals
scripts/checkModuleIdentifiers.js masks by construction and could never catch.
Found by the integration kit while writing the chapter that teaches this shape
to an audience outside this org - which is what that phase is for.
So the module says WHERE, in its own vocabulary, and WHICH of core's
contributions goes there:
declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
and core offers into the catalogue rather than into a name:
offerCoreFill('team.activity', TeamActivityFeed)
CORE_CONTRIBUTIONS is exported and fixed at build time, so asking for one core
does not offer THROWS at the declaration. That asymmetry with an unfilled slot
is deliberate: an unknown contribution is always a typo or a version skew - the
module's coreApi range has already been checked - and the failure it would
otherwise produce is a page that renders empty forever with nothing logged.
options.core is optional; a slot that asks for nothing stays empty, which is
what a module declaring a place it fills itself wants. More than one slot may
ask for the same contribution and each gets it: how many places a module wants
its feed in is a layout decision on a page core does not own.
Amends MODULE_API 1.6.0 in place rather than adding 1.7.0 - the same rule the
eighth and ninth members were given, and 1.6.0 has only ever been on edge.
Also: the UI kit is nine exports, not eight. Slot made it nine in phase 3 and
the comment beside it still said eighth.
288 client tests, 1162 server tests.
Co-Authored-By: Claude <noreply@anthropic.com>
210 lines
9.1 KiB
JavaScript
210 lines
9.1 KiB
JavaScript
import { test, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import {
|
|
registry,
|
|
declareSlot,
|
|
declareModuleSlot,
|
|
offerCoreFill,
|
|
CORE_CONTRIBUTIONS,
|
|
applyCoreFills,
|
|
registerExtension,
|
|
extensionFor,
|
|
registeredIds,
|
|
_reset,
|
|
} from '../src/modules/registry.js'
|
|
|
|
// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of
|
|
// the server's declareSlot/registerExtension.
|
|
//
|
|
// The registry half only. `<Slot>` itself renders, and there is no DOM in this
|
|
// runner, so what it does with what these functions return — including the error
|
|
// boundary — is proved by the §7.7 browser smoke instead. Everything below is a
|
|
// rule that can be stated without rendering anything, and every one of them can
|
|
// be got wrong in a way a browser check would not obviously catch.
|
|
|
|
beforeEach(() => _reset())
|
|
|
|
const Fake = () => null
|
|
const Other = () => null
|
|
|
|
test('an unfilled slot reads as nothing', () => {
|
|
// The guarantee core's layouts rest on: place a slot, install no module, and
|
|
// the page renders what it rendered before.
|
|
declareSlot('site.footer.status')
|
|
assert.equal(extensionFor('site.footer.status'), null)
|
|
})
|
|
|
|
test('an undeclared slot reads as nothing rather than throwing', () => {
|
|
// Reading is core's side and stays fail-safe: a typo in a layout costs that
|
|
// spot, not the page. Only WRITING is strict, which is the next test.
|
|
assert.equal(extensionFor('nope'), null)
|
|
})
|
|
|
|
test('a module fills a declared slot and core reads it back', () => {
|
|
declareSlot('admin.users.detail')
|
|
registerExtension('uo', 'admin.users.detail', Fake)
|
|
assert.equal(extensionFor('admin.users.detail'), Fake)
|
|
assert.deepEqual(registeredIds(), ['uo'])
|
|
})
|
|
|
|
test('filling an unknown slot throws, naming the slot', () => {
|
|
// This is the one place the client registry is NOT fail-open, and the reason
|
|
// is asymmetry of consequence: a dropped nav row costs a link the viewer can
|
|
// reach another way, a silently dropped extension is invisible to everyone
|
|
// including its author. Declaration structurally precedes filling (§3.1), so
|
|
// this can only ever be a typo or a version skew.
|
|
assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/)
|
|
})
|
|
|
|
test('a non-component fill throws', () => {
|
|
declareSlot('site.footer.status')
|
|
assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/)
|
|
})
|
|
|
|
test('a second module cannot take a filled slot, and the first keeps it', () => {
|
|
// Matches the server's rule exactly (registries.js): first fill wins, second
|
|
// is an error. The second half of the assertion is the one that matters — a
|
|
// rejected fill must not have half-replaced the incumbent.
|
|
declareSlot('admin.users.detail')
|
|
registerExtension('uo', 'admin.users.detail', Fake)
|
|
assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/)
|
|
assert.equal(extensionFor('admin.users.detail'), Fake)
|
|
})
|
|
|
|
test('declaring a slot twice throws', () => {
|
|
// Core-side programming error: two owners for one position means whichever
|
|
// module registered first wins by file order.
|
|
declareSlot('site.footer.status')
|
|
assert.throws(() => declareSlot('site.footer.status'), /already declared/)
|
|
})
|
|
|
|
test('core fills a slot through the same seam a module uses', () => {
|
|
// The client twin of registries.registerCore(). Core is a registrant with an
|
|
// id like any other, which is what makes slice 3 a deletion: the module
|
|
// registers the same slot and core drops its line.
|
|
declareSlot('site.footer.status')
|
|
registerExtension('core', 'site.footer.status', Fake)
|
|
assert.deepEqual(registeredIds(), ['core'])
|
|
})
|
|
|
|
test('declareSlot and extensionFor are not on the module-facing registry', () => {
|
|
// Declaring is core's alone (§3.7), and reading who filled a slot is core's
|
|
// too — the same line featureProviders() draws. registerExtension IS on the
|
|
// object, because filling is the whole point.
|
|
assert.equal(registry.declareSlot, undefined)
|
|
assert.equal(registry.extensionFor, undefined)
|
|
assert.equal(typeof registry.registerExtension, 'function')
|
|
})
|
|
|
|
// ── The INVERTED direction: the module declares, core fills ────────────────
|
|
//
|
|
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
|
|
// core surface — core owns the tables and the activity feed, the module owns the
|
|
// page and the word "guild" — so the content flows the other way for the first
|
|
// time. The rules below are the ones that direction gets wrong.
|
|
|
|
const Feed = () => null
|
|
|
|
test('a module-declared slot must be namespaced under the declaring module', () => {
|
|
// Enforced rather than conventional: this is the only thing keeping two
|
|
// modules from claiming the same slot name.
|
|
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
|
|
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
|
|
})
|
|
|
|
test('core offers a contribution and the module says where it goes', () => {
|
|
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
|
|
// module chunk, so at the moment core offers, no module-declared slot exists.
|
|
offerCoreFill('team.activity', Feed)
|
|
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
|
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
|
|
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
|
})
|
|
|
|
test('core names no slot, so a second game gets the same content in its own words', () => {
|
|
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
|
|
// which reached exactly one module. Every other game declared a place under its
|
|
// own id and got an empty page with no error, because a fill nobody declared is
|
|
// deliberately not an error — the rule that makes an unknown name invisible.
|
|
offerCoreFill('team.activity', Feed)
|
|
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
|
|
})
|
|
|
|
test('two modules can ask for the same contribution, and both get it', () => {
|
|
// Core has no reason to care how many places want its feed, and refusing the
|
|
// second would be core making a layout decision on a page it does not own.
|
|
offerCoreFill('team.activity', Feed)
|
|
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
|
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
|
assert.equal(extensionFor('uo.guild.summary'), Feed)
|
|
})
|
|
|
|
test('a slot that asks for nothing stays empty', () => {
|
|
// Optional on purpose: a module may declare a place it fills itself, or one it
|
|
// is keeping for later. Neither is core's business.
|
|
offerCoreFill('team.activity', Feed)
|
|
declareModuleSlot('uo', 'uo.guild.detail')
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('uo.guild.detail'), null)
|
|
})
|
|
|
|
test('asking for a contribution core does not offer THROWS', () => {
|
|
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
|
|
// contribution is always a typo or a version skew — core's list is fixed at
|
|
// build time and the module's coreApi range has already been checked — and the
|
|
// alternative failure is a page that renders empty forever with nothing logged.
|
|
assert.throws(
|
|
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
|
|
/does not offer/,
|
|
)
|
|
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
|
|
})
|
|
|
|
test('a contribution nothing asks for is not an error', () => {
|
|
// No game module installed. Core offering content for a page that does not
|
|
// exist is the ordinary case on any deployment, not a misconfiguration.
|
|
offerCoreFill('team.forum', Feed)
|
|
assert.doesNotThrow(() => applyCoreFills())
|
|
})
|
|
|
|
test('a module that fills its own slot first keeps it', () => {
|
|
const Own = () => null
|
|
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
|
registerExtension('uo', 'uo.guild.detail', Own)
|
|
offerCoreFill('team.activity', Feed)
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
|
|
})
|
|
|
|
test('a module-declared slot cannot be declared twice', () => {
|
|
declareModuleSlot('uo', 'uo.guild.detail')
|
|
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
|
|
})
|
|
|
|
test('applying the fills twice does not re-fill or throw', () => {
|
|
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
|
offerCoreFill('team.activity', Feed)
|
|
applyCoreFills()
|
|
assert.doesNotThrow(() => applyCoreFills())
|
|
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
|
})
|
|
|
|
test('a non-component contribution is refused at the call site, not at render', () => {
|
|
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
|
|
})
|
|
|
|
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
|
offerCoreFill('team.activity', Feed)
|
|
_reset()
|
|
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
|
|
applyCoreFills()
|
|
assert.equal(extensionFor('uo.guild.detail'), null)
|
|
})
|