mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Add property tests for the topics table view model
- Add client property tests for buildTopicsTable: one row per topic in input order, four-cell alignment with the header order, link percent-encoding round trips, and post/follower count conservation over random topic lists that also omit count/last-seen fields. - No production change: the coder's view model already scans at CRAP 1.0 with zero dry4javascript candidates and only 2 mutation sites. The JSX Topics component stays a thin, tool-excluded shell over the testable model. Verification: client 447 unit / 94 property / 34 acceptance + lint + build; indexer 113 unit / 12 property / 7 acceptance + lint; db 392 unit / 57 property / 16 acceptance + lint. By refactorer.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Property tests for the topics table view model.
|
||||
|
||||
The unit tests probe buildTopicsTable at fixed fixtures. These properties
|
||||
pin down the layout contract over broad random topic lists:
|
||||
|
||||
- Row order: exactly one row per topic, in the input order.
|
||||
- Cell alignment: every row has one cell per header, in the header order,
|
||||
with the topic name, relative-time label, post count, and follower count
|
||||
each in its matching column.
|
||||
- Link round trip: each row link percent-encodes the room and decodes back
|
||||
to it.
|
||||
- Count conservation: the digits in the post/follower cells sum to the
|
||||
input post/follower counts.
|
||||
|
||||
`now` is injected so every property is deterministic.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const {
|
||||
buildTopicsTable,
|
||||
TOPICS_TABLE_HEADERS,
|
||||
TOPICS_TABLE_WRAPPER_CLASS
|
||||
} = require('../../src/services/topics-table')
|
||||
const { relativeTime } = require('../../src/services/relative-time')
|
||||
|
||||
const rng = seededRandom(20260918)
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
const NOW = 1800000000000
|
||||
|
||||
// Room names mix plain words, spaces, path separators, unicode, and a leading
|
||||
// '#', so the link encoder and the '#room' label are exercised.
|
||||
const ROOM_NAMES = ['bitcoin', 'cash', 'space room', 'a/b', 'são paulo', '#tagged', 'emoji-😀', '']
|
||||
|
||||
function randomRoom () {
|
||||
return `${ROOM_NAMES[Math.floor(rng() * ROOM_NAMES.length)]}${intGen(rng, 0, 999)()}`
|
||||
}
|
||||
|
||||
// Fields are sometimes omitted so the default count/last-seen handling is part
|
||||
// of the generated input space.
|
||||
function topicGen () {
|
||||
const topic = { room: randomRoom() }
|
||||
if (rng() < 0.85) topic.postCount = intGen(rng, 0, 10000)()
|
||||
if (rng() < 0.85) topic.followerCount = intGen(rng, 0, 10000)()
|
||||
if (rng() < 0.85) topic.lastSeen = NOW - intGen(rng, 0, 400 * DAY)()
|
||||
return topic
|
||||
}
|
||||
|
||||
function topicsGen () {
|
||||
return () => Array.from({ length: intGen(rng, 0, 20)() }, topicGen)
|
||||
}
|
||||
|
||||
test('buildTopicsTable keeps one row per topic in the same order', async () => {
|
||||
await forAll(
|
||||
topicsGen(),
|
||||
(topics) => {
|
||||
const table = buildTopicsTable(topics, { now: NOW })
|
||||
if (table.rows.length !== topics.length) return false
|
||||
return table.rows.every((row, i) => row.room === topics[i].room)
|
||||
},
|
||||
{ label: 'topics table row order' }
|
||||
)
|
||||
})
|
||||
|
||||
test('buildTopicsTable keeps the four cells aligned with the header order', async () => {
|
||||
await forAll(
|
||||
topicsGen(),
|
||||
(topics) => {
|
||||
const table = buildTopicsTable(topics, { now: NOW })
|
||||
if (JSON.stringify(table.headers) !== JSON.stringify(TOPICS_TABLE_HEADERS)) return false
|
||||
if (table.wrapperClass !== TOPICS_TABLE_WRAPPER_CLASS) return false
|
||||
return table.rows.every((row, i) => {
|
||||
const topic = topics[i]
|
||||
return row.cells.length === TOPICS_TABLE_HEADERS.length &&
|
||||
row.cells[0] === `#${topic.room}` &&
|
||||
row.cells[1] === relativeTime(topic.lastSeen, NOW) &&
|
||||
row.cells[2] === `${topic.postCount ?? 0} posts` &&
|
||||
row.cells[3] === `${topic.followerCount ?? 0} followers`
|
||||
})
|
||||
},
|
||||
{ label: 'topics table cell alignment' }
|
||||
)
|
||||
})
|
||||
|
||||
test('buildTopicsTable percent-encodes row links that round-trip the room', async () => {
|
||||
await forAll(
|
||||
topicsGen(),
|
||||
(topics) => {
|
||||
const table = buildTopicsTable(topics, { now: NOW })
|
||||
return table.rows.every((row) => {
|
||||
if (row.href !== `/topics/${encodeURIComponent(row.room)}`) return false
|
||||
return decodeURIComponent(row.href.slice('/topics/'.length)) === row.room
|
||||
})
|
||||
},
|
||||
{ label: 'topics table link round trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('buildTopicsTable conserves post and follower counts into the cells', async () => {
|
||||
await forAll(
|
||||
topicsGen(),
|
||||
(topics) => {
|
||||
const table = buildTopicsTable(topics, { now: NOW })
|
||||
const expectedPosts = topics.reduce((sum, topic) => sum + (topic.postCount ?? 0), 0)
|
||||
const expectedFollowers = topics.reduce((sum, topic) => sum + (topic.followerCount ?? 0), 0)
|
||||
const posted = table.rows.reduce((sum, row) => sum + Number.parseInt(row.cells[2], 10), 0)
|
||||
const followers = table.rows.reduce((sum, row) => sum + Number.parseInt(row.cells[3], 10), 0)
|
||||
return posted === expectedPosts && followers === expectedFollowers
|
||||
},
|
||||
{ label: 'topics table count conservation' }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user