refactor(teams)!: Teams is a contract, not a surface — invert the slots
Org lead's correction, and it changes what this phase ships.
TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.
Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.
Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.
**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.
Three things about the inverted direction are load-bearing:
- the name is namespaced under the declaring module and that is enforced, not
conventional: it is the only thing keeping two modules off one name;
- core's fills are applied at MOUNT rather than eagerly. Core's bundle
evaluates before every module chunk, so when core registers a fill the slot
does not exist yet — filling eagerly would silently do nothing;
- a fill for a slot nobody declared is a no-op, never an error. The declaring
module is simply not installed, which is the ordinary case. That is the
opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
there, core declares first, so an unknown name is always a typo.
`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.
`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
|
||||
// declares a place on its own page and core fills it (TEAMS.md Part 3).
|
||||
'declareModuleSlot',
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerExtension',
|
||||
|
||||
@@ -4,6 +4,9 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
registry,
|
||||
declareSlot,
|
||||
declareModuleSlot,
|
||||
fillModuleSlot,
|
||||
applyCoreFills,
|
||||
registerExtension,
|
||||
extensionFor,
|
||||
registeredIds,
|
||||
@@ -92,3 +95,76 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
|
||||
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 fills a module slot only after the module has declared it', () => {
|
||||
// The ordering that makes this a separate call: core's bundle evaluates BEFORE
|
||||
// any module chunk, so at the moment core registers its fill the slot does not
|
||||
// exist yet. Filling eagerly would silently do nothing.
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it')
|
||||
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied')
|
||||
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a fill for a slot nobody declared is not an error', () => {
|
||||
// The module is not installed. Core offering content for a page that does not
|
||||
// exist is the ordinary case on any deployment, not a misconfiguration — the
|
||||
// mirror of an unfilled slot rendering nothing.
|
||||
fillModuleSlot('rust.clan.detail', Feed)
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('rust.clan.detail'), null)
|
||||
})
|
||||
|
||||
test('a module that fills its own slot first keeps it', () => {
|
||||
const Own = () => null
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
registerExtension('uo', 'uo.guild.detail', Own)
|
||||
fillModuleSlot('uo.guild.detail', 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')
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
applyCoreFills()
|
||||
assert.doesNotThrow(() => applyCoreFills())
|
||||
assert.equal(extensionFor('uo.guild.detail'), Feed)
|
||||
})
|
||||
|
||||
test('a non-component fill is refused at the call site, not at render', () => {
|
||||
assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/)
|
||||
})
|
||||
|
||||
test('_reset clears pending fills, so one test cannot leak into the next', () => {
|
||||
fillModuleSlot('uo.guild.detail', Feed)
|
||||
_reset()
|
||||
declareModuleSlot('uo', 'uo.guild.detail')
|
||||
applyCoreFills()
|
||||
assert.equal(extensionFor('uo.guild.detail'), null)
|
||||
})
|
||||
|
||||
78
client/test/teamActivity.test.js
Normal file
78
client/test/teamActivity.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// What core's Team activity feed says (client/src/lib/teamActivity.js).
|
||||
//
|
||||
// The test that earns this file: a projection nobody can tell is stale, and a
|
||||
// feed nobody can tell is filtered, both look like complete information. Every
|
||||
// case below is about saying which one the reader is looking at.
|
||||
//
|
||||
// Note the wording assertions avoid core's own noun. The feed renders inside a
|
||||
// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
|
||||
// core's vocabulary leaking onto a surface that deliberately does not use it.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
|
||||
|
||||
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
||||
const ago = (ms) => new Date(NOW - ms).toISOString()
|
||||
|
||||
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||
})
|
||||
|
||||
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /Not yet confirmed/)
|
||||
})
|
||||
|
||||
test('a stale projection says how old it is and that the game may have moved on', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
||||
})
|
||||
|
||||
test('a current projection is stated quietly', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
||||
assert.equal(note.tone, 'idle')
|
||||
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
||||
})
|
||||
|
||||
test('relative time singularises and steps through the units', () => {
|
||||
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
||||
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
||||
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
||||
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
||||
assert.equal(relativeTime(null, NOW), null)
|
||||
assert.equal(relativeTime('not a date', NOW), null)
|
||||
})
|
||||
|
||||
test('items group into days, newest day first, order kept within a day', () => {
|
||||
const days = groupByDay([
|
||||
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
||||
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
||||
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
||||
], 'en-US')
|
||||
assert.equal(days.length, 2)
|
||||
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
||||
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
||||
})
|
||||
|
||||
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
||||
assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
|
||||
})
|
||||
|
||||
test('a caller who saw everything is told nothing', () => {
|
||||
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
||||
})
|
||||
|
||||
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
||||
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
||||
assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
|
||||
})
|
||||
|
||||
test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
|
||||
for (const signedIn of [true, false]) {
|
||||
assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
|
||||
}
|
||||
assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
|
||||
})
|
||||
@@ -1,162 +0,0 @@
|
||||
// What the public Team pages say (docs/website/TEAMS.md §3.2, §3.3, §4.3).
|
||||
//
|
||||
// lib/teams.js is plain JS precisely so these can be asserted without a DOM. The
|
||||
// cases worth protecting are the ones where a wrong sentence is a false statement
|
||||
// about the game rather than a cosmetic slip — an empty roster reported as "no
|
||||
// members" when the module could not be asked being the clearest.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
LINK_STATE,
|
||||
activityScopeNote,
|
||||
emptyRosterReason,
|
||||
filterTeams,
|
||||
freshnessNote,
|
||||
groupByDay,
|
||||
linkStateOf,
|
||||
relativeTime,
|
||||
rosterSummary,
|
||||
sortTeams,
|
||||
} from '../src/lib/teams.js'
|
||||
|
||||
// ── The roster header ──────────────────────────────────────────────────────
|
||||
|
||||
test('the header states what each number is, so the gap reads as information', () => {
|
||||
assert.equal(rosterSummary({ members: 37, linked: 21 }), '37 members · 21 linked')
|
||||
})
|
||||
|
||||
test('one member is not "1 members"', () => {
|
||||
assert.equal(rosterSummary({ members: 1, linked: 1 }), '1 member · 1 linked')
|
||||
})
|
||||
|
||||
test('forum guests appear only once there are some', () => {
|
||||
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 0 }), '37 members · 21 linked')
|
||||
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 4 }), '37 members · 21 linked · 4 forum guests')
|
||||
assert.equal(rosterSummary({ members: 2, linked: 1, guests: 1 }), '2 members · 1 linked · 1 forum guest')
|
||||
})
|
||||
|
||||
test('an absent roster still produces a sentence rather than NaN', () => {
|
||||
assert.equal(rosterSummary(), '0 members · 0 linked')
|
||||
})
|
||||
|
||||
test('link state is a value, not an absence', () => {
|
||||
assert.equal(linkStateOf({ linked: true }), LINK_STATE.linked)
|
||||
assert.equal(linkStateOf({ linked: false }), LINK_STATE.unlinked)
|
||||
assert.equal(linkStateOf(undefined), LINK_STATE.unlinked)
|
||||
})
|
||||
|
||||
// ── Freshness ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
||||
const ago = (ms) => new Date(NOW - ms).toISOString()
|
||||
|
||||
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
||||
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
||||
})
|
||||
|
||||
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.match(note.text, /Not yet confirmed/)
|
||||
})
|
||||
|
||||
test('a stale projection says how old it is and that the game may have moved on', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
||||
assert.equal(note.tone, 'warn')
|
||||
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
||||
})
|
||||
|
||||
test('a current projection is stated quietly', () => {
|
||||
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
||||
assert.equal(note.tone, 'idle')
|
||||
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
||||
})
|
||||
|
||||
test('relative time singularises and steps through the units', () => {
|
||||
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
||||
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
||||
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
||||
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
||||
assert.equal(relativeTime(null, NOW), null)
|
||||
assert.equal(relativeTime('not a date', NOW), null)
|
||||
})
|
||||
|
||||
// ── Why a roster is empty ──────────────────────────────────────────────────
|
||||
|
||||
test('a populated roster has no explaining to do', () => {
|
||||
assert.equal(emptyRosterReason({ members: [{}] }), null)
|
||||
})
|
||||
|
||||
test('a module that could not be asked is never reported as an empty guild', () => {
|
||||
// The failure this function exists to prevent: saying something false about
|
||||
// the game because core could not reach the module.
|
||||
const reason = emptyRosterReason({ members: [], projectionUnavailable: true })
|
||||
assert.match(reason, /could not be reached/)
|
||||
})
|
||||
|
||||
test('an unconfirmed projection says so rather than claiming the Team is empty', () => {
|
||||
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: null })
|
||||
assert.match(reason, /not been confirmed/)
|
||||
})
|
||||
|
||||
test('a genuinely empty, confirmed roster says the plain thing', () => {
|
||||
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: ago(1000) })
|
||||
assert.equal(reason, 'Nobody is in this Team.')
|
||||
})
|
||||
|
||||
// ── The activity feed ──────────────────────────────────────────────────────
|
||||
|
||||
test('items group into days, newest day first, order kept within a day', () => {
|
||||
const days = groupByDay([
|
||||
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
||||
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
||||
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
||||
], 'en-US')
|
||||
assert.equal(days.length, 2)
|
||||
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
||||
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
||||
})
|
||||
|
||||
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
||||
const days = groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US')
|
||||
assert.deepEqual(days, [])
|
||||
})
|
||||
|
||||
test('a caller who saw everything is told nothing', () => {
|
||||
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
||||
})
|
||||
|
||||
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
||||
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
||||
assert.match(activityScopeNote({ scope: 'public' }, true), /members of this Team only/)
|
||||
})
|
||||
|
||||
// ── The index ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('teams sort by size then by name', () => {
|
||||
const sorted = sortTeams([
|
||||
{ name: 'Zephyr', memberCount: 3 },
|
||||
{ name: 'Anvil', memberCount: 10 },
|
||||
{ name: 'Bell', memberCount: 3 },
|
||||
])
|
||||
assert.deepEqual(sorted.map((t) => t.name), ['Anvil', 'Bell', 'Zephyr'])
|
||||
})
|
||||
|
||||
test('sorting does not mutate its input', () => {
|
||||
const input = [{ name: 'B', memberCount: 1 }, { name: 'A', memberCount: 9 }]
|
||||
sortTeams(input)
|
||||
assert.equal(input[0].name, 'B')
|
||||
})
|
||||
|
||||
test('search matches the two things a visitor knows a Team by', () => {
|
||||
const teams = [{ name: 'The Silver Hand', abbr: 'TSH' }, { name: 'Anvil', abbr: 'ANV' }]
|
||||
assert.deepEqual(filterTeams(teams, 'silver').map((t) => t.abbr), ['TSH'])
|
||||
assert.deepEqual(filterTeams(teams, 'anv').map((t) => t.abbr), ['ANV'])
|
||||
assert.equal(filterTeams(teams, ' ').length, 2)
|
||||
assert.equal(filterTeams(teams, 'nothing').length, 0)
|
||||
})
|
||||
|
||||
test('search survives a team with no abbreviation', () => {
|
||||
assert.doesNotThrow(() => filterTeams([{ name: 'Anvil', abbr: null }], 'a'))
|
||||
})
|
||||
Reference in New Issue
Block a user