diff --git a/psf-memo-client/specs/notification-entry-display.feature b/psf-memo-client/specs/notification-entry-display.feature index b52aead..95f7663 100644 --- a/psf-memo-client/specs/notification-entry-display.feature +++ b/psf-memo-client/specs/notification-entry-display.feature @@ -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 diff --git a/psf-memo-client/src/components/app-body/notifications/index.js b/psf-memo-client/src/components/app-body/notifications/index.js index 7f7a570..9946a18 100644 --- a/psf-memo-client/src/components/app-body/notifications/index.js +++ b/psf-memo-client/src/components/app-body/notifications/index.js @@ -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} /> ))} diff --git a/psf-memo-client/src/components/app-body/notifications/notification-entry.js b/psf-memo-client/src/components/app-body/notifications/notification-entry.js index e7d7552..a69fcc3 100644 --- a/psf-memo-client/src/components/app-body/notifications/notification-entry.js +++ b/psf-memo-client/src/components/app-body/notifications/notification-entry.js @@ -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 diff --git a/psf-memo-client/src/services/notification-entry.js b/psf-memo-client/src/services/notification-entry.js index e4c66bc..e5c3e72 100644 --- a/psf-memo-client/src/services/notification-entry.js +++ b/psf-memo-client/src/services/notification-entry.js @@ -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 diff --git a/psf-memo-client/src/services/notifications-page.js b/psf-memo-client/src/services/notifications-page.js index 24ab8d8..da0c99f 100644 --- a/psf-memo-client/src/services/notifications-page.js +++ b/psf-memo-client/src/services/notifications-page.js @@ -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 diff --git a/psf-memo-client/test/unit/notification-entry-component.test.js b/psf-memo-client/test/unit/notification-entry-component.test.js new file mode 100644 index 0000000..42f4bf1 --- /dev/null +++ b/psf-memo-client/test/unit/notification-entry-component.test.js @@ -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, /]+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, / { + 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>/) +}) diff --git a/psf-memo-client/test/unit/notifications-page.test.js b/psf-memo-client/test/unit/notifications-page.test.js index 607a2b8..7499367 100644 --- a/psf-memo-client/test/unit/notifications-page.test.js +++ b/psf-memo-client/test/unit/notifications-page.test.js @@ -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' },