mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Refactor topic actions: reduce CRAP/DRY, add property coverage, complete acceptance
- Add unit coverage for Profiles topic follow state to bring CRAP to 4. - DRY Profiles set/get follow state via shared nested-map helpers. - Add TopicQuery topic-follow read-side property tests and MemoTopicPost byte-accounting property tests. - Complete client and DB acceptance step handlers and mocks for the topic-follow and topic-post features. By refactorer.
This commit is contained in:
@@ -38,6 +38,9 @@ const ProfilePage = require('../../src/services/profile-page')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||
const TopicFeedPage = require('../../src/services/topic-feed-page')
|
||||
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
|
||||
const MemoTopicPost = require('../../src/services/memo-topic-post')
|
||||
const TopicPostPage = require('../../src/services/topic-post-page')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
||||
@@ -47,6 +50,9 @@ const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX
|
||||
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
|
||||
const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX
|
||||
const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
|
||||
const MEMO_TOPIC_MESSAGE_PREFIX = MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX
|
||||
const MEMO_TOPIC_FOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX
|
||||
const MEMO_TOPIC_UNFOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX
|
||||
|
||||
// Default author address used by Gherkin steps that refer to "the author address".
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
@@ -98,11 +104,13 @@ function makeProfiles () {
|
||||
const bios = {}
|
||||
const avatarUrls = {}
|
||||
const following = {}
|
||||
const topicFollowing = {}
|
||||
return {
|
||||
names,
|
||||
bios,
|
||||
avatarUrls,
|
||||
following,
|
||||
topicFollowing,
|
||||
setName: (addr, name) => { names[addr] = name },
|
||||
getName: (addr) => names[addr] || null,
|
||||
setBio: (addr, bio) => { bios[addr] = bio },
|
||||
@@ -113,7 +121,12 @@ function makeProfiles () {
|
||||
if (!following[selfAddr]) following[selfAddr] = {}
|
||||
following[selfAddr][targetAddr] = isFollowing
|
||||
},
|
||||
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false
|
||||
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false,
|
||||
setTopicFollowState: (selfAddr, room, isFollowing) => {
|
||||
if (!topicFollowing[selfAddr]) topicFollowing[selfAddr] = {}
|
||||
topicFollowing[selfAddr][room] = isFollowing
|
||||
},
|
||||
getTopicFollowState: (selfAddr, room) => topicFollowing[selfAddr]?.[room] || false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +149,7 @@ function makeMemoDb () {
|
||||
const topics = []
|
||||
const topicPosts = {}
|
||||
const topicCounts = new Map()
|
||||
const topicFollow = new Map()
|
||||
|
||||
return {
|
||||
posts,
|
||||
@@ -171,6 +185,20 @@ function makeMemoDb () {
|
||||
setFollowState (followerAddr, followeeAddr, following) {
|
||||
followState[`${followerAddr}:${followeeAddr}`] = following
|
||||
},
|
||||
setTopicFollowState (addr, room, following) {
|
||||
if (!topicFollow.has(room)) topicFollow.set(room, new Map())
|
||||
topicFollow.get(room).set(addr, following)
|
||||
},
|
||||
async getTopicFollowState (room, addr) {
|
||||
return topicFollow.get(room)?.get(addr) || false
|
||||
},
|
||||
async getTopicFollowers (room) {
|
||||
const addrs = []
|
||||
for (const [addr, following] of (topicFollow.get(room) || new Map()).entries()) {
|
||||
if (following) addrs.push(addr)
|
||||
}
|
||||
return addrs
|
||||
},
|
||||
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
||||
const page = posts.slice(offset, offset + limit)
|
||||
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
|
||||
@@ -212,6 +240,7 @@ function createWorld () {
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
const memoFollow = new MemoFollow({ wallet, profiles })
|
||||
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
|
||||
const memoDb = makeMemoDb()
|
||||
|
||||
const world = {
|
||||
@@ -222,6 +251,7 @@ function createWorld () {
|
||||
memoReply,
|
||||
memoLike,
|
||||
memoFollow,
|
||||
memoTopicFollow,
|
||||
memoDb,
|
||||
currentPath: null,
|
||||
menuOpen: false,
|
||||
@@ -326,6 +356,13 @@ function findDisplayedPost (txid, world) {
|
||||
return null
|
||||
}
|
||||
|
||||
// True when the currently displayed page is a topic feed (used to dispatch the
|
||||
// shared "Follow/Unfollow button" steps between the profile page and the topic
|
||||
// feed page).
|
||||
function isTopicFeedActive (world) {
|
||||
return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/'))
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
@@ -1340,14 +1377,22 @@ const handlers = [
|
||||
name: 'click Follow button',
|
||||
pattern: /^I click the Follow button$/,
|
||||
async run (m, example, world) {
|
||||
await world.profilePage.follow()
|
||||
if (isTopicFeedActive(world)) {
|
||||
await world.topicFeedPage.follow()
|
||||
} else {
|
||||
await world.profilePage.follow()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Unfollow button',
|
||||
pattern: /^I click the Unfollow button$/,
|
||||
async run (m, example, world) {
|
||||
await world.profilePage.unfollow()
|
||||
if (isTopicFeedActive(world)) {
|
||||
await world.topicFeedPage.unfollow()
|
||||
} else {
|
||||
await world.profilePage.unfollow()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1471,12 +1516,23 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'open topic feed',
|
||||
pattern: /^I open the topic feed for "?(<topic>|[^"]+)"?$/,
|
||||
pattern: /^I open the topic feed for (?:the topic )?"?(<topic>|[^"]+)"?$/,
|
||||
async run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
world.topicFeedPage = new TopicFeedPage({ memoDb: world.memoDb, room })
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.topicFeedPage = new TopicFeedPage({
|
||||
memoDb: world.memoDb,
|
||||
room,
|
||||
myAddr,
|
||||
memoTopicFollow: world.memoTopicFollow
|
||||
})
|
||||
await world.topicFeedPage.load()
|
||||
world.currentPath = TopicFeedPage.topicFeedPath(room)
|
||||
|
||||
// Set up the topic post composer for this room so topic messages can be
|
||||
// composed and broadcast, reflecting new posts onto the shared feed.
|
||||
const memoTopicPost = new MemoTopicPost({ wallet: world.wallet, room, feed: world.feed })
|
||||
world.topicPostPage = new TopicPostPage({ memoTopicPost })
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1503,6 +1559,147 @@ const handlers = [
|
||||
throw new Error(`Expected topic feed to be empty, but found ${Array.isArray(posts) ? posts.length : 'non-array'} posts.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API reports I do not follow topic',
|
||||
pattern: /^the psf-memo-db API reports that I do not follow the topic (<topic>)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.memoDb.setTopicFollowState(myAddr, room, false)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API reports I follow topic',
|
||||
pattern: /^the psf-memo-db API reports that I follow the topic (<topic>)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.memoDb.setTopicFollowState(myAddr, room, true)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic feed page shows Follow button',
|
||||
pattern: /^the topic feed page shows a Follow button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
|
||||
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show a Follow button.')
|
||||
if (world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Unfollow, but Follow was expected.')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic feed page shows Unfollow button',
|
||||
pattern: /^the topic feed page shows an Unfollow button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
|
||||
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show an Unfollow button.')
|
||||
if (!world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Follow, but Unfollow was expected.')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts topic-follow prefix',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-follow prefix for the topic (<topic>)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_TOPIC_FOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo topic-follow prefix ${MEMO_TOPIC_FOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg !== room) {
|
||||
throw new Error(`Broadcast topic-follow payload did not match topic ${room}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts topic-unfollow prefix',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-unfollow prefix for the topic (<topic>)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_TOPIC_UNFOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo topic-unfollow prefix ${MEMO_TOPIC_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg !== room) {
|
||||
throw new Error(`Broadcast topic-unfollow payload did not match topic ${room}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'compose topic message',
|
||||
pattern: /^I compose a topic message with the text "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
if (!(param in example)) throw new Error(`Missing example value for "${param}"`)
|
||||
world.topicPostPage.setInput(example[param])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit topic message',
|
||||
pattern: /^I submit the topic message$/,
|
||||
async run (m, example, world) {
|
||||
await world.topicPostPage.submit()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts topic-message prefix',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-message prefix for the topic (<topic>)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_TOPIC_MESSAGE_PREFIX) {
|
||||
throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
const expectedPayload = room + world.topicPostPage.input
|
||||
if (last.msg !== expectedPayload) {
|
||||
throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic feed shows new post from my address',
|
||||
pattern: /^the topic feed shows a new post from my address with the text "(.+)"$/,
|
||||
run (m, example, world) {
|
||||
const expectedText = resolveParam(m[1], example)
|
||||
const myAddress = world.wallet.walletInfo.cashAddress
|
||||
const found = world.feed.posts.find((p) => p.text === expectedText && p.address === myAddress)
|
||||
if (!found) throw new Error(`Topic feed does not show the new post with text "${expectedText}".`)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic post composer shows validation error',
|
||||
pattern: /^the topic post composer shows a validation error$/,
|
||||
run (m, example, world) {
|
||||
if (world.topicPostPage.submitError !== 'topic_post_validation') {
|
||||
throw new Error(`Expected topic_post_validation, got ${world.topicPostPage.submitError}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic post composer shows length error',
|
||||
pattern: /^the topic post composer shows a length error$/,
|
||||
run (m, example, world) {
|
||||
if (world.topicPostPage.submitError !== 'topic_post_length') {
|
||||
throw new Error(`Expected topic_post_length, got ${world.topicPostPage.submitError}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic post composer remaining byte count',
|
||||
pattern: /^the topic post composer shows a remaining byte count of (<count>)$/,
|
||||
run (m, example, world) {
|
||||
const expected = parseInt(resolveParam(m[1], example), 10)
|
||||
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${m[1]}".`)
|
||||
const actual = world.topicPostPage.remainingCount()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -50,35 +50,40 @@ class Profiles {
|
||||
|
||||
// Track whether the current wallet follows a given address.
|
||||
setFollowState (selfAddr, targetAddr, isFollowing) {
|
||||
if (!selfAddr || !targetAddr) return
|
||||
if (!this.following.has(selfAddr)) {
|
||||
this.following.set(selfAddr, new Map())
|
||||
}
|
||||
this.following.get(selfAddr).set(targetAddr, isFollowing)
|
||||
this._setMapState(this.following, selfAddr, targetAddr, isFollowing)
|
||||
}
|
||||
|
||||
getFollowState (selfAddr, targetAddr) {
|
||||
if (!selfAddr || !targetAddr) return false
|
||||
return this.following.get(selfAddr)?.get(targetAddr) || false
|
||||
return this._getMapState(this.following, selfAddr, targetAddr)
|
||||
}
|
||||
|
||||
// Track whether the current wallet follows a given topic.
|
||||
setTopicFollowState (selfAddr, room, isFollowing) {
|
||||
if (!selfAddr || !room) return
|
||||
if (!this.topicFollowing.has(selfAddr)) {
|
||||
this.topicFollowing.set(selfAddr, new Map())
|
||||
}
|
||||
this.topicFollowing.get(selfAddr).set(room, isFollowing)
|
||||
this._setMapState(this.topicFollowing, selfAddr, room, isFollowing)
|
||||
}
|
||||
|
||||
getTopicFollowState (selfAddr, room) {
|
||||
if (!selfAddr || !room) return false
|
||||
return this.topicFollowing.get(selfAddr)?.get(room) || false
|
||||
return this._getMapState(this.topicFollowing, selfAddr, room)
|
||||
}
|
||||
|
||||
// Set a boolean value on a per-self-address nested map.
|
||||
_setMapState (map, selfAddr, key, value) {
|
||||
if (!selfAddr || !key) return
|
||||
if (!map.has(selfAddr)) {
|
||||
map.set(selfAddr, new Map())
|
||||
}
|
||||
map.get(selfAddr).set(key, value)
|
||||
}
|
||||
|
||||
// Read a boolean value from a per-self-address nested map.
|
||||
_getMapState (map, selfAddr, key) {
|
||||
if (!selfAddr || !key) return false
|
||||
return map.get(selfAddr)?.get(key) || false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Profiles
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-27T17:52:37.432Z","module_hash":"f729d4125eea29c45c4a0d7bd691a5f590512922f2ba78c85756643638fb0859","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":18,"hash":"574f0b3772f783646c283c6ddc4d5ff200ca1f9918ab1fa668205003f885d4d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":20,"end_line":23,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":25,"end_line":28,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":30,"end_line":33,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":35,"end_line":38,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":40,"end_line":43,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":45,"end_line":48,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":51,"end_line":57,"hash":"aa527936b6ca92a502e93e8241803318700c528e05f68ff7fb6fac1d410c197c"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":59,"end_line":62,"hash":"299a44adb289026bf3b89a21947fc23bf05f5a40f07abb7f1bbd311c0479f562"}]}
|
||||
// {"version":1,"tested_at":"2026-08-28T18:00:08.907Z","module_hash":"1cec5125fb228d43d47e4ec171eb43c492f2bd773ca315748df78a16071e2802","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":19,"hash":"247804207ec0220b6e6507f6beb6f86023a7c08b50737a52a0dbb99d262993d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":21,"end_line":24,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":26,"end_line":29,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":31,"end_line":34,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":36,"end_line":39,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":41,"end_line":44,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":46,"end_line":49,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":52,"end_line":54,"hash":"805cafb46052f91bfa0a123842c25ff8ee0f7f4a7e661f51d289b0487738dbd4"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":56,"end_line":58,"hash":"48fdf5a632825946c391146628cd6f51b4eb0169c7a6d6b89c795ff554e62b07"},{"id":"func/Profiles.setTopicFollowState","name":"Profiles.setTopicFollowState","line":61,"end_line":63,"hash":"54ab980e42018d64fbacee7d80347d530791b7ba21d8fac46f5ed85f33903f98"},{"id":"func/Profiles.getTopicFollowState","name":"Profiles.getTopicFollowState","line":65,"end_line":67,"hash":"90ce88799dda5477e20ce4de17404dace55abf1d2f2c1c87671e94fc2f270205"},{"id":"func/Profiles._setMapState","name":"Profiles._setMapState","line":70,"end_line":76,"hash":"13d2cad84be961b89482970f55f107d93fa1bdc10636dc64844c6a9263e88583"},{"id":"func/Profiles._getMapState","name":"Profiles._getMapState","line":79,"end_line":82,"hash":"c1c8966a797533614d35c7a13deb8f757572127ebcbde1d234c7d966346d89a8"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Property tests for the Memo topic-post byte accounting.
|
||||
|
||||
The unit tests probe isTooLong and remainingBytes at a few fixed fixtures.
|
||||
These properties pin down invariants that hold over broad random UTF-8
|
||||
inputs (mixed byte widths):
|
||||
|
||||
- byte accounting: remainingBytes(msg) always equals the combined byte
|
||||
budget minus the room and message byte lengths.
|
||||
- consistency: isTooLong(msg) is true exactly when remainingBytes(msg)
|
||||
is negative.
|
||||
- monotonicity: appending bytes never flips a too-long message back to
|
||||
being accepted.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const { seededRandom, forAll } = require('./harness')
|
||||
const MemoTopicPost = require('../../src/services/memo-topic-post')
|
||||
const { byteLength } = require('../../src/services/utf8')
|
||||
|
||||
const rng = seededRandom(20260901)
|
||||
const MAX = MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES
|
||||
|
||||
// Characters with distinct UTF-8 byte widths: 1, 1, 1, 2, 3, and 4 bytes.
|
||||
const CHARS = ['a', 'b', ' ', '\u00e9', '\u20ac', '\ud83d\ude00']
|
||||
|
||||
function textGen () {
|
||||
const len = Math.floor(rng() * 140)
|
||||
let out = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += CHARS[Math.floor(rng() * CHARS.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function postInputGen () {
|
||||
return () => ({ room: textGen(), msg: textGen() })
|
||||
}
|
||||
|
||||
test('remainingBytes equals the byte budget minus room and message bytes', async () => {
|
||||
await forAll(
|
||||
postInputGen(),
|
||||
({ room, msg }) => {
|
||||
const post = new MemoTopicPost({ room })
|
||||
return post.remainingBytes(msg) === MAX - byteLength(room) - byteLength(msg)
|
||||
},
|
||||
{ label: 'remainingBytes byte accounting' }
|
||||
)
|
||||
})
|
||||
|
||||
test('isTooLong is true exactly when remainingBytes is negative', async () => {
|
||||
await forAll(
|
||||
postInputGen(),
|
||||
({ room, msg }) => {
|
||||
const post = new MemoTopicPost({ room })
|
||||
return post.isTooLong(msg) === (post.remainingBytes(msg) < 0)
|
||||
},
|
||||
{ label: 'isTooLong / remainingBytes consistency' }
|
||||
)
|
||||
})
|
||||
|
||||
test('isTooLong is monotonic as message bytes are appended', async () => {
|
||||
await forAll(
|
||||
() => {
|
||||
const { room, msg } = postInputGen()()
|
||||
const extra = textGen()
|
||||
return { room, msg, extra }
|
||||
},
|
||||
({ room, msg, extra }) => {
|
||||
const post = new MemoTopicPost({ room })
|
||||
// Appending bytes can only make a message longer, so a too-long
|
||||
// message must remain too-long once more bytes are added.
|
||||
return !(post.isTooLong(msg) && !post.isTooLong(msg + extra))
|
||||
},
|
||||
{ label: 'isTooLong monotonicity' }
|
||||
)
|
||||
})
|
||||
@@ -64,7 +64,10 @@ test('TopicFeedPage.getPost finds any loaded post', async () => {
|
||||
return posts
|
||||
},
|
||||
async (posts) => {
|
||||
const memoDb = { async getTopicPosts () { return { posts, pagination: { total: posts.length } } } }
|
||||
const memoDb = {
|
||||
async getTopicPosts () { return { posts, pagination: { total: posts.length } } },
|
||||
async getTopicFollowers () { return [] }
|
||||
}
|
||||
const page = new TopicFeedPage({ memoDb, room: 'bitcoin' })
|
||||
await page.load()
|
||||
|
||||
|
||||
@@ -134,6 +134,64 @@ test('follow state storage is independent per self address', () => {
|
||||
assert.equal(profiles.getFollowState(otherSelfAddr, targetAddr), false)
|
||||
})
|
||||
|
||||
test('setTopicFollowState stores and getTopicFollowState retrieves topic follow state', () => {
|
||||
const profiles = new Profiles()
|
||||
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const room = 'bitcoin'
|
||||
|
||||
profiles.setTopicFollowState(selfAddr, room, true)
|
||||
|
||||
assert.equal(profiles.getTopicFollowState(selfAddr, room), true)
|
||||
})
|
||||
|
||||
test('getTopicFollowState returns false for an unknown topic relationship', () => {
|
||||
const profiles = new Profiles()
|
||||
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), false)
|
||||
})
|
||||
|
||||
test('setTopicFollowState with no self address does nothing', () => {
|
||||
const profiles = new Profiles()
|
||||
|
||||
profiles.setTopicFollowState('', 'bitcoin', true)
|
||||
|
||||
assert.equal(profiles.getTopicFollowState('', 'bitcoin'), false)
|
||||
})
|
||||
|
||||
test('setTopicFollowState with no room does nothing', () => {
|
||||
const profiles = new Profiles()
|
||||
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
profiles.setTopicFollowState(selfAddr, '', true)
|
||||
|
||||
assert.equal(profiles.getTopicFollowState(selfAddr, ''), false)
|
||||
})
|
||||
|
||||
test('topic follow state storage is independent per self address', () => {
|
||||
const profiles = new Profiles()
|
||||
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const otherSelfAddr = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
|
||||
|
||||
profiles.setTopicFollowState(selfAddr, 'bitcoin', true)
|
||||
profiles.setTopicFollowState(otherSelfAddr, 'bitcoin', false)
|
||||
|
||||
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true)
|
||||
assert.equal(profiles.getTopicFollowState(otherSelfAddr, 'bitcoin'), false)
|
||||
})
|
||||
|
||||
test('topic follow state storage is independent of address follow state', () => {
|
||||
const profiles = new Profiles()
|
||||
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
|
||||
profiles.setFollowState(selfAddr, targetAddr, true)
|
||||
profiles.setTopicFollowState(selfAddr, 'bitcoin', true)
|
||||
|
||||
assert.equal(profiles.getFollowState(selfAddr, targetAddr), true)
|
||||
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true)
|
||||
})
|
||||
|
||||
test('avatar URL storage is independent of name and bio storage', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
Reference in New Issue
Block a user