Refactor notification entry display: share acceptance assertions, add property tests

Deduplicate the notification-entry acceptance step handlers behind shared
entry-assert helpers and collapse the repeated ready-state guards in the
notifications view. Add property tests for the entry view model (profile link
round trip, name fallback, view-model derivation, message mapping) and for
notifications page profile resolution, entry derivation, and actor lookup.

CRAP stays at or below 6 and every changed source module stays below 100
mutation sites; mutation manifests are left intact for differential runs.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-18 12:30:53 -07:00
parent 521d434524
commit 7dfc315840
4 changed files with 328 additions and 60 deletions
+34 -57
View File
@@ -3282,80 +3282,39 @@ const handlers = [
name: 'notification entry shows display name',
pattern: /^the notification entry from the address (.+) shows the display name "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.displayName !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the display name "${expected}", got "${entry.displayName}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(expected)) {
throw new Error(`Rendered notification entry does not show the display name "${expected}".`)
}
assertEntryShows(world, resolveParam(m[1], example), 'displayName', resolveParam(m[2], example))
}
},
{
name: 'notification entry shows avatar',
pattern: /^the notification entry from the address (.+) shows the avatar "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.avatarUrl !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the avatar "${expected}", got "${entry.avatarUrl}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(`src="${expected}"`)) {
throw new Error(`Rendered notification entry does not show the avatar "${expected}".`)
}
assertEntryShows(world, resolveParam(m[1], example), 'avatarUrl', expected, `src="${expected}"`)
}
},
{
name: 'notification entry shows address as plain text',
pattern: /^the notification entry from the address (.+) shows the address (.+) as plain text$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const shown = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.addr !== shown) {
throw new Error(`Expected the notification entry from ${addr} to show the address ${shown}.`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(`notification-entry-address">${shown}<`)) {
throw new Error(`Rendered notification entry does not show the address ${shown} as plain text.`)
}
assertEntryShows(world, resolveParam(m[1], example), 'addr', shown, `notification-entry-address">${shown}<`)
}
},
{
name: 'notification entry links avatar to profile',
pattern: /^the notification entry from the address (.+) links the avatar to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the avatar to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), 'notification-entry-avatar-link')
if (href !== expected) {
throw new Error(`Rendered notification avatar does not link to ${expected}.`)
}
assertEntryProfileLink(world, resolveParam(m[1], example), resolveParam(m[2], example),
'notification-entry-avatar-link', 'avatar')
}
},
{
name: 'notification entry links display name to profile',
pattern: /^the notification entry from the address (.+) links the display name to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the display name to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), 'notification-entry-name-link')
if (href !== expected) {
throw new Error(`Rendered notification display name does not link to ${expected}.`)
}
assertEntryProfileLink(world, resolveParam(m[1], example), resolveParam(m[2], example),
'notification-entry-name-link', 'display name')
}
},
{
@@ -3392,15 +3351,7 @@ const handlers = [
name: 'notification entry shows reply text',
pattern: /^the notification entry from the address (.+) shows the reply text "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.text !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the reply text "${expected}", got "${entry.text}".`)
}
if (!renderNotificationEntry(entry).includes(expected)) {
throw new Error(`Rendered notification entry does not show the reply text "${expected}".`)
}
assertEntryShows(world, resolveParam(m[1], example), 'text', resolveParam(m[2], example))
}
},
{
@@ -4063,6 +4014,32 @@ function notificationEntryFor (world, addr) {
return entry
}
// Assert that a notification entry exposes `field` as `expected` in its view
// model and that the rendered markup contains `rendered` (defaulting to the
// expected value itself).
function assertEntryShows (world, addr, field, expected, rendered = expected) {
const entry = notificationEntryFor(world, addr)
if (entry[field] !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show ${field} "${expected}", got "${entry[field]}".`)
}
if (!renderNotificationEntry(entry).includes(rendered)) {
throw new Error(`Rendered notification entry does not show ${field} "${expected}".`)
}
}
// Assert that a notification entry links the anchor carrying `className` to the
// expected profile path, both in the view model and the rendered markup.
function assertEntryProfileLink (world, addr, expected, className, label) {
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the ${label} to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), className)
if (href !== expected) {
throw new Error(`Rendered notification ${label} does not link to ${expected}.`)
}
}
// Extract the image tags from a rendered HTML string. The acceptance adapter
// renders a small, controlled HTML subset, so a regex match is sufficient.
function imagesIn (html) {
@@ -82,6 +82,8 @@ function Notifications (props) {
setOffset((prev) => prev + PAGE_SIZE)
}
const ready = !loading && !error
return (
<Container className='notifications-page'>
<Row className='justify-content-center'>
@@ -114,11 +116,11 @@ function Notifications (props) {
</div>
)}
{!loading && !error && entries.length === 0 && (
{ready && entries.length === 0 && (
<p className='notifications-empty'>You have no notifications.</p>
)}
{!loading && !error && entries.length > 0 && (
{ready && entries.length > 0 && (
<div className='notifications-list'>
{entries.map((entry) => (
<NotificationEntry
@@ -130,7 +132,7 @@ function Notifications (props) {
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
{ready && (pagination || offset > 0) && (
<div className='notifications-pagination'>
<Button
variant='outline-dark'
@@ -0,0 +1,126 @@
/*
Property tests for the notification entry view model.
The unit tests probe fixed fixtures. These properties pin the view-model
contract over broad random inputs so it holds everywhere:
- Profile link round trip: profilePath percent-encodes the address and
decodes back to it.
- Name fallback: displayName is the profile name when present, and the
truncated address otherwise.
- Derivation: buildNotificationEntry preserves every notification field
and derives the avatar, link, View Post flag, display name, and message
from the notification and resolved profile.
- Message mapping: notificationMessage describes exactly the reply, like,
follow, and unknown types.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const {
PROFILE_PATH_PREFIX,
VIEW_POST_TYPES,
profilePath,
displayName,
notificationMessage,
buildNotificationEntry
} = require('../../src/services/notification-entry')
const { truncateAddr } = require('../../src/util')
const rng = seededRandom(20260918)
const TYPES = ['like', 'reply', 'follow', 'mention', '', 'unknown']
const ADDR_CHARS = 'abcXYZ0123456789:qpzry9x8gf2tvdw0s3jn54khce6mua7l'
const NAMES = ['alice', 'bob', 'Zoë', ' spaced ', '0', '']
// An address-shaped string that exercises encoding without using characters
// that encodeURIComponent leaves ambiguous (such as '%').
function randomAddr () {
const length = intGen(rng, 1, 60)()
let addr = ''
for (let i = 0; i < length; i++) {
addr += ADDR_CHARS[Math.floor(rng() * ADDR_CHARS.length)]
}
return addr
}
// A profile with optional name and picture, or null for "not resolved".
function randomProfile () {
if (rng() < 0.2) return null
const profile = {}
if (rng() < 0.8) profile.name = NAMES[Math.floor(rng() * NAMES.length)]
if (rng() < 0.8) profile.profilePicUrl = `https://example.com/${intGen(rng, 0, 999)()}.png`
return profile
}
function randomNotification () {
const notification = {
type: TYPES[Math.floor(rng() * TYPES.length)],
txid: `tx-${intGen(rng, 0, 999)()}`,
addr: randomAddr()
}
if (rng() < 0.7) notification.postTxid = `post-${intGen(rng, 0, 999)()}`
if (rng() < 0.7) notification.text = `text ${intGen(rng, 0, 999)()}`
return notification
}
test('profilePath percent-encodes the address and round-trips it', async () => {
await forAll(
randomAddr,
(addr) => {
const path = profilePath(addr)
return path.startsWith(`${PROFILE_PATH_PREFIX}/`) &&
decodeURIComponent(path.slice(PROFILE_PATH_PREFIX.length + 1)) === addr
},
{ label: 'notification entry profile path round trip' }
)
})
test('displayName prefers a non-empty profile name and truncates otherwise', async () => {
await forAll(
() => ({ addr: randomAddr(), profile: randomProfile() }),
({ addr, profile }) => {
const expected = profile?.name || truncateAddr(addr, 24)
return displayName(addr, profile) === expected
},
{ label: 'notification entry display name fallback' }
)
})
test('buildNotificationEntry preserves the notification and derives the view fields', async () => {
await forAll(
() => ({ notification: randomNotification(), profile: randomProfile() }),
({ notification, profile }) => {
const entry = buildNotificationEntry(notification, profile)
return entry.type === notification.type &&
entry.txid === notification.txid &&
entry.addr === notification.addr &&
entry.postTxid === notification.postTxid &&
entry.text === notification.text &&
entry.displayName === (profile?.name || truncateAddr(notification.addr, 24)) &&
entry.avatarUrl === (profile?.profilePicUrl || null) &&
entry.profilePath === `${PROFILE_PATH_PREFIX}/${encodeURIComponent(notification.addr)}` &&
entry.showViewPost === VIEW_POST_TYPES.includes(notification.type) &&
entry.message === notificationMessage(notification)
},
{ label: 'notification entry view-model derivation' }
)
})
test('notificationMessage describes exactly the reply, like, follow, and unknown types', async () => {
await forAll(
randomNotification,
(notification) => {
const message = notificationMessage(notification)
if (notification.type === 'reply') {
return message === `replied to your post: ${notification.text || ''}`
}
if (notification.type === 'like') return message === 'liked your post'
if (notification.type === 'follow') return message === 'followed you'
return message === ''
},
{ label: 'notification entry message mapping' }
)
})
@@ -0,0 +1,163 @@
/*
Property tests for the notifications page profile resolution.
The unit tests probe fixed profile fixtures. These properties pin the
controller's profile-loading contract over broad random notification lists:
- Resolution: load resolves exactly one profile per distinct actor address,
using the name and picture records when present and an empty profile when
a field is missing or the lookup throws.
- Derivation: getEntries builds one entry per notification whose display
name, avatar, profile link, and View Post flag match the notification and
resolved profile.
- Lookup: getEntryByAddr returns the entry for a loaded actor and null
otherwise.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const NotificationsPage = require('../../src/services/notifications-page')
const { truncateAddr } = require('../../src/util')
const rng = seededRandom(20260918)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const TYPES = ['like', 'reply', 'follow']
function makeWallet () {
return { walletInfo: { cashAddress: MY_ADDRESS } }
}
// Per-address profile data: present, partial, or a throwing lookup.
function randomProfileData () {
const roll = rng()
if (roll < 0.2) return { throws: true }
if (roll < 0.5) return {}
return {
name: rng() < 0.7 ? `name-${intGen(rng, 0, 99)()}` : null,
profilePicUrl: rng() < 0.7 ? `https://example.com/${intGen(rng, 0, 99)()}.png` : null
}
}
function fixtureGen () {
return () => {
const addrs = Array.from(
{ length: intGen(rng, 0, 4)() },
(unused, i) => `addr-${i}`
)
const notifications = addrs.map((addr) => ({
type: TYPES[Math.floor(rng() * TYPES.length)],
txid: `tx-${addr}`,
addr,
postTxid: `post-${addr}`,
text: `body ${addr}`
}))
const profiles = {}
for (const addr of addrs) profiles[addr] = randomProfileData()
return { addrs, notifications, profiles }
}
}
function makeMemoDb (notifications, profiles) {
return {
async getNotifications () {
return { notifications, pagination: { total: notifications.length } }
},
async getName (addr) {
const profile = profiles[addr]
if (profile?.throws) throw new Error('profile lookup failed')
return profile?.name ? { name: profile.name } : null
},
async getProfilePic (addr) {
const profile = profiles[addr]
if (profile?.throws) throw new Error('profile lookup failed')
return profile?.profilePicUrl ? { url: profile.profilePicUrl } : null
}
}
}
// The expected resolved profile for an address, mirroring the controller's
// fallback rules.
function expectedProfile (profile) {
if (!profile || profile.throws) return { name: null, profilePicUrl: null }
return {
name: profile.name || null,
profilePicUrl: profile.profilePicUrl || null
}
}
test('load resolves exactly one profile per distinct actor address', async () => {
await forAll(
fixtureGen(),
async ({ notifications, profiles }) => {
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, profiles),
wallet: makeWallet()
})
const result = await page.load()
const expectedAddrs = [...new Set(notifications.map((n) => n.addr))].sort()
const actualAddrs = Object.keys(result.profiles).sort()
if (JSON.stringify(actualAddrs) !== JSON.stringify(expectedAddrs)) return false
return expectedAddrs.every((addr) => {
const expected = expectedProfile(profiles[addr])
const actual = result.profiles[addr]
return actual.name === expected.name &&
actual.profilePicUrl === expected.profilePicUrl
})
},
{ label: 'notifications page profile resolution' }
)
})
test('getEntries derives each entry from the notification and resolved profile', async () => {
await forAll(
fixtureGen(),
async ({ notifications, profiles }) => {
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, profiles),
wallet: makeWallet()
})
await page.load()
const entries = page.getEntries()
if (entries.length !== notifications.length) return false
return entries.every((entry, i) => {
const notification = notifications[i]
const profile = expectedProfile(profiles[notification.addr])
return entry.addr === notification.addr &&
entry.displayName === (profile.name || truncateAddr(notification.addr, 24)) &&
entry.avatarUrl === profile.profilePicUrl &&
entry.profilePath === `/profile/${encodeURIComponent(notification.addr)}` &&
entry.showViewPost === (notification.type === 'like' || notification.type === 'reply')
})
},
{ label: 'notifications page entry derivation' }
)
})
test('getEntryByAddr returns the first matching entry and null otherwise', async () => {
await forAll(
fixtureGen(),
async ({ notifications, profiles }) => {
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, profiles),
wallet: makeWallet()
})
await page.load()
if (notifications.length === 0) return true
const first = notifications[0]
const entry = page.getEntryByAddr(first.addr)
return entry !== null &&
entry.txid === first.txid &&
page.getEntryByAddr('addr-missing') === null
},
{ label: 'notifications page actor lookup' }
)
})