Review notification entry display: wire SPA profile links, cover renderer

Wire the existing onProfileClick seam in the Notifications page so the actor
avatar and display name navigate through the SPA router instead of a full-page
reload (which 404s on the BrowserRouter/GH Pages deployment). Add a component
unit test so the renderer's mutation sites are covered, and a fresh-state
controller test to kill the constructor's empty=false mutant.

Refresh the tool-written mutation manifests for the changed modules and the
soft Gherkin mutation manifest for the feature.

By architect.
This commit is contained in:
Chris Troutner
2026-09-18 12:43:39 -07:00
parent 7dfc315840
commit bbbf4adcd7
7 changed files with 124 additions and 1 deletions
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-18T19:43:11.863411027Z","feature_name":"Notification Entry Display","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/notification-entry-display.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Notification Entry Display - 2 the avatar and display name link to the actor's profile","scenario_hash":"6eac4433d642f99b7538085e60169c835655026202b1abee6b2264fe01d52711","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-09-18T19:43:11.863411027Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Notification Entry Display - 1, Notification Entry Display - 2, Notification Entry Display - 3, Notification Entry Display - 4, Notification Entry Display - 5, Notification Entry Display - 6, Notification Entry Display - 7, Notification Entry Display - 8
#
# Each Notifications entry names its actor with the actor's Memo display name
@@ -10,6 +10,7 @@
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
// Local libraries
@@ -24,6 +25,7 @@ const PAGE_SIZE = 50
function Notifications (props) {
const { appData } = props
const wallet = appData?.wallet
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -127,6 +129,7 @@ function Notifications (props) {
key={entry.txid}
entry={entry}
onViewPost={openThread}
onProfileClick={navigate}
/>
))}
</div>
@@ -95,3 +95,7 @@ function NotificationEntry ({ entry, onViewPost, onProfileClick }) {
module.exports = NotificationEntry
module.exports.NotificationAvatar = NotificationAvatar
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-18T19:39:02.274Z","module_hash":"98bffd92542fbbee866abbc2775b60736c2ea8ba6fe36ec38690b22035fdd306","functions":[{"id":"func/NotificationAvatar","name":"NotificationAvatar","line":23,"end_line":39,"hash":"eaae01344873b8451a42943bd87478a7b4e8dc3fa5021efd2811c932950e40bf"},{"id":"func/NotificationEntry","name":"NotificationEntry","line":41,"end_line":94,"hash":"c17002fa1ff4602c2785990a95100f4ff782ebb1a5dc7c5573190d85a5bdfe4f"}]}
// mutate4javascript-manifest-end
@@ -61,3 +61,7 @@ module.exports = {
notificationMessage,
buildNotificationEntry
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-18T19:33:01.460Z","module_hash":"8043c8e67b2834a2e0667b5e9d528ef96bb0f25ac2d45cf87ecc00505e89171e","functions":[{"id":"func/profilePath","name":"profilePath","line":18,"end_line":20,"hash":"600b1f8066ecb8274c17a26c6ac2c753471c3ee4efc2de69558c6811a070a82c"},{"id":"func/displayName","name":"displayName","line":24,"end_line":26,"hash":"4c3a77ba28e6e2e1ec50c7bf6854a51f3fe6ed85f23faa8d48745898c1ee580a"},{"id":"func/notificationMessage","name":"notificationMessage","line":29,"end_line":40,"hash":"5dd8314d32ab92867f655414252398c224e804a71b83a0bd96a18fb32595297f"},{"id":"func/buildNotificationEntry","name":"buildNotificationEntry","line":44,"end_line":53,"hash":"4707908e71b73429351a2a283aaf4e68093be66dd0e06ee0af7188fe65bcea7d"}]}
// mutate4javascript-manifest-end
@@ -114,5 +114,5 @@ NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
module.exports = NotificationsPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-04T17:20:15.460Z","module_hash":"302d668c6360824911c04786d5337002fe2870aed064fdb3ba85b3944e6c4f7b","functions":[{"id":"func/NotificationsPage.constructor","name":"NotificationsPage.constructor","line":13,"end_line":19,"hash":"52eca9540d0f0c524cd8b2c6c2a80c37cc1769c2a109bae7c97f080bede56130"},{"id":"func/NotificationsPage.getMyAddress","name":"NotificationsPage.getMyAddress","line":21,"end_line":23,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/NotificationsPage.load","name":"NotificationsPage.load","line":25,"end_line":45,"hash":"d99ba2df1db799b969e7fc4b8e3f0964c84f973d92e048df97c361e4659b0c9d"},{"id":"func/NotificationsPage.canLoadMore","name":"NotificationsPage.canLoadMore","line":47,"end_line":49,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/NotificationsPage.getNotification","name":"NotificationsPage.getNotification","line":51,"end_line":53,"hash":"d01238c7fddb85e9ca7427d512c828ec6524657feb1156a820516641ed27105c"}]}
// {"version":1,"tested_at":"2026-09-18T19:40:56.858Z","module_hash":"db9565ee912fd31eed870808332e98f57e4b9c01b573a03bd11fe36a66fb6989","functions":[{"id":"func/NotificationsPage.constructor","name":"NotificationsPage.constructor","line":15,"end_line":22,"hash":"e9f52dab100cd73a4149081fd1e1ac59bcfbfe58a3a3ef32f2f2bf83c613f9ab"},{"id":"func/NotificationsPage.getMyAddress","name":"NotificationsPage.getMyAddress","line":24,"end_line":26,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/NotificationsPage.load","name":"NotificationsPage.load","line":28,"end_line":50,"hash":"aeba628bcf9228754a2a57807a9bf8bb84236ed95a8d89c5493de9ef362cb937"},{"id":"func/NotificationsPage._loadProfiles","name":"NotificationsPage._loadProfiles","line":54,"end_line":60,"hash":"bf78658c8e7d10ed90d64ede96ccdc215f07045834fe1e44f54f25c0af4d54a6"},{"id":"func/NotificationsPage._loadProfile","name":"NotificationsPage._loadProfile","line":64,"end_line":77,"hash":"f5de92a5e6a20e82d365c30749d086d255b665f23b916aa2a2f03ad08798dd63"},{"id":"func/NotificationsPage._loadProfileField","name":"NotificationsPage._loadProfileField","line":79,"end_line":82,"hash":"8d3814b475e60aa26a45bb99c4f2d3ffc6cc78225e319c85c073afad0c611086"},{"id":"func/NotificationsPage.canLoadMore","name":"NotificationsPage.canLoadMore","line":84,"end_line":86,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/NotificationsPage.getNotification","name":"NotificationsPage.getNotification","line":88,"end_line":90,"hash":"d01238c7fddb85e9ca7427d512c828ec6524657feb1156a820516641ed27105c"},{"id":"func/NotificationsPage.getEntries","name":"NotificationsPage.getEntries","line":93,"end_line":95,"hash":"9481b5ecf1a0f770df7d4eb764e695387d6362ee58a0936ea3914357292dec14"},{"id":"func/NotificationsPage.getEntry","name":"NotificationsPage.getEntry","line":98,"end_line":102,"hash":"5a259b305609fd6e8529588a571f4ba0397fb8d93c69569a68d8d5ad4b942033"},{"id":"func/NotificationsPage.getEntryByAddr","name":"NotificationsPage.getEntryByAddr","line":105,"end_line":109,"hash":"a99ae43f45702447dd3abfa5f8dd2ffa8acf79b655a24765299f84709bc98624"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,99 @@
/*
Unit tests for the notification entry renderer.
The renderer names its actor with the display name and avatar, links both to
the actor's profile, shows the full address as plain text, and offers a
"View Post" link only for like and reply notifications. It falls back to an
identicon when the actor has no avatar.
Written against the same rendering adapter seam the acceptance suite uses so
the module stays covered by the standard unit suite.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const NotificationEntry = require('../../src/components/app-body/notifications/notification-entry')
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const PROFILE_PATH = `/profile/${encodeURIComponent(ALICE)}`
const VIEW_POST_LABEL = 'View Post'
function makeEntry (overrides = {}) {
return {
type: 'like',
txid: 'a'.repeat(64),
addr: ALICE,
postTxid: 'b'.repeat(64),
displayName: 'alice',
avatarUrl: null,
profilePath: PROFILE_PATH,
showViewPost: true,
message: 'liked your post',
...overrides
}
}
function render (entry) {
return ReactDOMServer.renderToStaticMarkup(
React.createElement(NotificationEntry, { entry })
)
}
test('renders nothing without an entry', () => {
assert.equal(render(null), '')
})
test('renders the display name, full address, and message', () => {
const html = render(makeEntry())
assert.ok(html.includes('alice'))
assert.ok(html.includes(ALICE))
assert.ok(html.includes('liked your post'))
})
test('renders the avatar image when the actor has an avatar URL', () => {
const html = render(makeEntry({ avatarUrl: 'https://example.com/alice.png' }))
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/alice\.png"/)
assert.doesNotMatch(html, /notification-entry-identicon/)
})
test('renders an identicon when the actor has no avatar URL', () => {
const html = render(makeEntry({ avatarUrl: null }))
assert.match(html, /notification-entry-identicon/)
assert.match(html, /data-jdenticon-value/)
assert.doesNotMatch(html, /<img/)
})
test('links the avatar and display name to the actor profile', () => {
const html = render(makeEntry())
assert.ok(html.includes('class="notification-entry-avatar-link"'))
assert.ok(html.includes('class="notification-entry-name-link"'))
assert.equal(html.split(`href="${PROFILE_PATH}"`).length - 1, 2)
})
test('offers a View Post link when the notification references a post', () => {
const html = render(makeEntry({ showViewPost: true }))
assert.ok(html.includes(VIEW_POST_LABEL))
assert.ok(html.includes('class="notification-entry-view-post"'))
})
test('offers no View Post link when the notification does not reference a post', () => {
const html = render(makeEntry({ showViewPost: false }))
assert.ok(!html.includes(VIEW_POST_LABEL))
assert.ok(!html.includes('notification-entry-view-post'))
})
test('renders an empty message when the notification has no text', () => {
const html = render(makeEntry({ message: undefined }))
assert.match(html, /class="notification-entry-text"[^>]*><\/p>/)
})
@@ -24,6 +24,15 @@ function makeMemoDb (notifications, pagination) {
}
}
test('a fresh page starts with unloaded defaults', () => {
const page = new NotificationsPage({})
assert.equal(page.empty, false)
assert.equal(page.pagination, null)
assert.deepEqual(page.notifications, [])
assert.deepEqual(page.profiles, {})
})
test('load returns notifications', async () => {
const notifications = [
{ type: 'reply', txid: 'a'.repeat(64), addr: 'bitcoincash:other', text: 'hi' },