mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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' }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user