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
@@ -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' }
)
})