mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Harden recent profile identity with property tests
Add seeded property tests for the new identity join and table view model: profile-path round trips, display-name fallback, account mapping, table row shape, DB identity-field independence, missing-store null behavior, and page-only join with conserved order and pagination. Cover the account-cell click handler so RecentProfileAccount reaches 100% unit coverage (CRAP <= 2). Tests only; no production behavior changed. By refactorer.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Property tests for the Recent Profiles table view model.
|
||||
|
||||
The unit tests probe buildRecentProfileAccount and buildRecentProfilesTable at
|
||||
fixed fixtures. These properties pin the view-model contract over broad random
|
||||
profiles:
|
||||
|
||||
- Link round trip: the profile path percent-encodes the address and decodes
|
||||
back to it, for addresses mixing real cash-addr characters, routing
|
||||
punctuation, reserved URL characters, and unicode.
|
||||
- Display-name fallback: a non-empty name is shown verbatim; an absent name
|
||||
falls back to the truncated address.
|
||||
- Account mapping: the account preserves the address and maps the name,
|
||||
avatar (null when absent), and profile path.
|
||||
- Table shape: the headers stay fixed and there is exactly one account row
|
||||
per input profile, in input order.
|
||||
|
||||
All generation is seeded, so runs are reproducible.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const {
|
||||
PROFILE_PATH_PREFIX,
|
||||
RECENT_PROFILES_TABLE_HEADERS,
|
||||
profilePath,
|
||||
accountDisplayName,
|
||||
buildRecentProfileAccount,
|
||||
buildRecentProfilesTable
|
||||
} = require('../../src/services/recent-profiles-table')
|
||||
const { truncateAddr } = require('../../src/util')
|
||||
|
||||
const rng = seededRandom(20260920)
|
||||
|
||||
const ADDR_CHARS = Array.from('abcdefghijklmnopqrstuvwxyz0123456789:?&=#%+/ são😀')
|
||||
const NAMES = ['alice', 'bob', '名字', 'a/b', '', null, undefined]
|
||||
|
||||
function randomAddr () {
|
||||
const length = intGen(rng, 1, 64)()
|
||||
let addr = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
addr += ADDR_CHARS[Math.floor(rng() * ADDR_CHARS.length)]
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
function randomName () {
|
||||
return NAMES[Math.floor(rng() * NAMES.length)]
|
||||
}
|
||||
|
||||
function randomProfile () {
|
||||
const profile = { addr: randomAddr(), name: randomName() }
|
||||
const roll = rng()
|
||||
if (roll < 0.6) {
|
||||
profile.profilePicUrl = `https://example.com/${encodeURIComponent(randomAddr())}.png`
|
||||
} else if (roll < 0.8) {
|
||||
profile.profilePicUrl = null
|
||||
} else {
|
||||
profile.profilePicUrl = undefined
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
function profileListGen () {
|
||||
return () => Array.from({ length: intGen(rng, 0, 12)() }, randomProfile)
|
||||
}
|
||||
|
||||
test('profilePath percent-encodes the address and round-trips it', async () => {
|
||||
await forAll(
|
||||
(i) => randomAddr(),
|
||||
(addr) => {
|
||||
const path = profilePath(addr)
|
||||
if (!path.startsWith(`${PROFILE_PATH_PREFIX}/`)) return false
|
||||
return decodeURIComponent(path.slice(PROFILE_PATH_PREFIX.length + 1)) === addr
|
||||
},
|
||||
{ label: 'recent profile path round trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('accountDisplayName prefers a non-empty name and falls back to the truncated address', async () => {
|
||||
await forAll(
|
||||
(i) => ({ addr: randomAddr(), name: randomName() }),
|
||||
({ addr, name }) => accountDisplayName(addr, name) === (name || truncateAddr(addr, 24)),
|
||||
{ label: 'recent profile display name fallback' }
|
||||
)
|
||||
})
|
||||
|
||||
test('buildRecentProfileAccount preserves the address and maps name, avatar, and path', async () => {
|
||||
await forAll(
|
||||
(i) => randomProfile(),
|
||||
(profile) => {
|
||||
const account = buildRecentProfileAccount(profile)
|
||||
return account.addr === profile.addr &&
|
||||
account.displayName === accountDisplayName(profile.addr, profile.name) &&
|
||||
account.avatarUrl === (profile.profilePicUrl || null) &&
|
||||
account.profilePath === profilePath(profile.addr)
|
||||
},
|
||||
{ label: 'recent profile account mapping' }
|
||||
)
|
||||
})
|
||||
|
||||
test('buildRecentProfilesTable keeps one account row per profile in input order', async () => {
|
||||
await forAll(
|
||||
profileListGen(),
|
||||
(profiles) => {
|
||||
const table = buildRecentProfilesTable(profiles)
|
||||
if (JSON.stringify(table.headers) !== JSON.stringify(RECENT_PROFILES_TABLE_HEADERS)) return false
|
||||
if (table.rows.length !== profiles.length) return false
|
||||
return table.rows.every((row, i) => {
|
||||
if (row.addr !== profiles[i].addr) return false
|
||||
return JSON.stringify(row.account) === JSON.stringify(buildRecentProfileAccount(profiles[i]))
|
||||
})
|
||||
},
|
||||
{ label: 'recent profiles table rows' }
|
||||
)
|
||||
})
|
||||
@@ -66,3 +66,32 @@ test('links the avatar and display name to the profile', () => {
|
||||
assert.ok(html.includes('class="recent-profile-name-link"'))
|
||||
assert.equal(html.split(`href="${PROFILE_PATH}"`).length - 1, 2)
|
||||
})
|
||||
|
||||
// Invoke the component function directly so the anchor click handlers can be
|
||||
// exercised without a DOM.
|
||||
function tree (props) {
|
||||
return RecentProfileAccount(props)
|
||||
}
|
||||
|
||||
test('clicking either account link prevents the default and navigates to the profile', () => {
|
||||
const clicked = []
|
||||
const [avatarLink, nameLink] = tree({
|
||||
account: makeAccount(),
|
||||
onProfileClick: (path) => clicked.push(path)
|
||||
}).props.children
|
||||
|
||||
let prevented = 0
|
||||
const event = { preventDefault: () => { prevented += 1 } }
|
||||
avatarLink.props.onClick(event)
|
||||
nameLink.props.onClick(event)
|
||||
|
||||
assert.equal(prevented, 2)
|
||||
assert.deepEqual(clicked, [PROFILE_PATH, PROFILE_PATH])
|
||||
})
|
||||
|
||||
test('clicking an account link without a navigation handler is a no-op', () => {
|
||||
const [avatarLink, nameLink] = tree({ account: makeAccount() }).props.children
|
||||
|
||||
assert.doesNotThrow(() => avatarLink.props.onClick({ preventDefault: () => {} }))
|
||||
assert.doesNotThrow(() => nameLink.props.onClick({ preventDefault: () => {} }))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user