diff --git a/package.json b/package.json index cb38dfc..13e9949 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "node --test \"test/unit/*.test.js\"", + "test:property": "node --test \"test/property/*.test.js\"", "test:acceptance": "node acceptance/acceptance.js", "eject": "react-scripts eject", "lint": "standard --env mocha --fix", diff --git a/test/property/harness.js b/test/property/harness.js new file mode 100644 index 0000000..0ccf9af --- /dev/null +++ b/test/property/harness.js @@ -0,0 +1,48 @@ +/* + Small property-testing harness for psf-memo-client. + + Node's built-in test runner has no property-based generator, so this module + provides a tiny deterministic, seeded pseudo-random generator plus a helper + to run a property across many samples and report a counterexample. All + generation is seeded, so runs are reproducible. +*/ + +'use strict' + +const assert = require('node:assert/strict') + +// A small deterministic PRNG (mulberry32). Same seed => same stream. +function seededRandom (seed = 12345) { + let a = seed >>> 0 + return function next () { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Run a property across N samples. `gen` returns a fresh input; `check` +// returns true when the property holds. Asserts a counterexample on failure. +async function forAll (gen, check, { samples = 500, label = 'property' } = {}) { + for (let i = 0; i < samples; i++) { + const input = gen(i) + const ok = await check(input) + assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`) + } +} + +// Generate a random ASCII string of a given length using a seeded RNG. +function makeStringGen (rng) { + return (length) => { + const chars = [] + for (let i = 0; i < length; i++) { + // Mix of printable ASCII (32..126). + chars.push(String.fromCharCode(32 + Math.floor(rng() * 95))) + } + return chars.join('') + } +} + +module.exports = { seededRandom, forAll, makeStringGen } diff --git a/test/property/memo-post.property.test.js b/test/property/memo-post.property.test.js new file mode 100644 index 0000000..b848f56 --- /dev/null +++ b/test/property/memo-post.property.test.js @@ -0,0 +1,112 @@ +/* + Property tests for the Memo post / New Post behavior slices. + + These assert useful invariants that unit tests cover only at a few fixed + points: + - Validation classification is stable across a broad input range: any + non-blank string up to the length limit is accepted, any longer string is + rejected with a length error, and blank/non-string input is a validation + error. + - The New Post character counter conserves its relationship to input + length: input.length + remainingCount() === MAX_MEMO_CHARS for any string. + - setInput round-trips the exact draft text. +*/ + +'use strict' + +const test = require('node:test') + +const { seededRandom, forAll, makeStringGen } = require('./harness') + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') + +const MAX = MemoPost.MAX_MEMO_CHARS // 217 + +const rng = seededRandom(20260826) +const stringOf = makeStringGen(rng) + +function buildPage () { + return new NewPostPage({ + memoPost: new MemoPost({}), + navigate: () => {}, + menuLinks: [] + }) +} + +test('memo validation: any non-blank string at or below the limit is valid', async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * MAX) // 1..MAX + return stringOf(len) + }, + (msg) => { + // A random ASCII string may occasionally be all whitespace; whitespace-only + // input is a validation error, so only assert for non-blank strings. + if (msg.trim().length === 0) return true + const result = new MemoPost({}).validate(msg) + return result.ok === true + }, + { label: 'valid length' } + ) +}) + +test('memo validation: any string above the limit is a length error', async () => { + await forAll( + (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX + (msg) => { + const result = new MemoPost({}).validate(msg) + return result.ok === false && result.type === 'length' + }, + { label: 'over-long rejected as length' } + ) +}) + +test('memo validation: blank and non-string input are validation errors', async () => { + await forAll( + (i) => (i % 2 === 0 ? ' ' : null), + (msg) => { + const result = new MemoPost({}).validate(msg) + return result.ok === false && result.type === 'validation' + }, + { label: 'blank/non-string rejected as validation' } + ) +}) + +test('character counter conserves length: remaining === MAX - input.length', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * (MAX + 8))), + (msg) => { + const page = buildPage() + page.setInput(msg) + return page.remainingCount() === MAX - msg.length + }, + { label: 'counter conservation' } + ) +}) + +test('setInput round-trips the draft text exactly', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * 50)), + (msg) => { + const page = buildPage() + page.setInput(msg) + return page.input === msg + }, + { label: 'setInput round-trip' } + ) +}) + +test('menu link registration is idempotent', async () => { + await forAll( + (i) => `/posts/${i}`, + (path) => { + const page = buildPage() + page.addMenuLink(path) + page.addMenuLink(path) + page.addMenuLink(path) + return page.menuLinks.filter((p) => p === path).length === 1 + }, + { label: 'menu link idempotence' } + ) +})