Add posting-state coverage and persist mutation state

Cover the new-post posting flag (initial idle, in-flight true, settled
false) to kill the four surviving mutants, and commit the differential and
acceptance mutation manifests written by the approved tools during the
architect verification pass for the new-post-page feature.

By architect.
This commit is contained in:
Chris Troutner
2026-08-25 17:09:14 -07:00
parent d57975c466
commit 009fba71fd
6 changed files with 106 additions and 2 deletions
+62
View File
@@ -0,0 +1,62 @@
# Architectural Review Summary — new-post-page
## Task and commits reviewed
- Task: `new-post-page`
- Reviewed the merged branch ending at `d57975c466` (refactorer), which carried:
- `9385034`/`4fa92d1` — specifier New Post Page Gherkin spec (`specs/memo-new.feature`)
- `05f393c` — coder implementation (`src/services/new-post.js`, page wiring)
- `d57975c` — refactorer property tests for memo post / new post invariants
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
## Architectural findings and fixes applied
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
local code quality.
1. **Testable controller behind adapters (good).** `src/services/new-post.js` is a
testable controller wrapping `memo-post.js`; it holds draft input, the remaining-
character counter, typed validation/length errors, and feed navigation. `memoPost`
and `navigate` are injected so the module stays free of UI/IO concerns. Dependency
direction is inward.
2. **Unified acceptance handlers (good).** `acceptance/lib/handlers.js` now drives both
`post-memo.feature` and `memo-new.feature` through one `world.newPage`, reusing the
shared Memo post behavior. Wording differences are normalized via shared regex
alternations; no step logic is duplicated.
3. **Property tests (good).** `test/property/{harness,memo-post.property.test}.js`
assert seeded invariants (validation classification across the length boundary,
counter conservation `remaining === MAX - len`, `setInput` round-trip, menu-link
idempotence). Kept separate from unit tests as the architecture requires.
4. **Fix applied — posting-state coverage.** The language mutation tool surfaced 4
survivors in `new-post.js`, all around the `posting` state flag (initial value and
its true/false transitions in `submit`). Added unit coverage asserting the page
starts idle, is `posting=true` while a submit is in flight (via a deferred wallet),
and returns to `posting=false` on success and on error. This killed all 4 survivors.
## Verification results
- **Unit (`node --test`):** 18/18 pass (added 2 posting-state tests).
- **Property (`npm run test:property`):** 6/6 pass.
- **Acceptance (normal):** both `memo-new` and `post-memo` generated suites pass.
- **Mutation (`mutate4javascript`, `--max-workers 8`):**
- `memo-post.js`: differential reuse (module unchanged since its 7/7 kill).
- `new-post.js`: after coverage fix, **killed 11 / survived 0 / uncovered 0**
(was 4 survivors before the fix).
- **DRY (`dry4javascript src`):** no duplicate candidates.
- **Gherkin acceptance mutation (soft):**
- `memo-new.feature`: 11 executed — **5 killed, 6 survived**, 0 errors.
- Killed: empty-memo boundary; character-counter `count` example values (217/212/0)
— proving those values are connected to behavior.
- Survived (documented equivalents): message-content dithers (4) plus case/length-
neutral dithers in the counter scenario — message text is opaque data; only length
affects the counter, so these don't change observable behavior.
- `post-memo.feature`: empty-memo scenario reused as killed; remaining 5 executed are
the same documented message-content equivalents.
- Property tests: run separately via `npm run test:property`.
## Suite status
- Unit + property + acceptance all pass; source-level mutation fully kills both testable
core modules. Gherkin acceptance mutation survivors are documented equivalents.
## Handoffs sent
- `git_handoff` → coder, refactorer (priority `00`, task `new-post-page`), to review the
architect commit (posting-state coverage + tool manifests).
By architect.
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T00:08:15.433121898Z","feature_name":"New Post Page","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T00:08:15.433121898Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5
Feature: New Post Page
+1 -1
View File
@@ -1,5 +1,5 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-25T23:22:59.013044939Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]}
# {"version":1,"tested_at":"2026-08-26T00:08:35.176401424Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
+1 -1
View File
@@ -97,5 +97,5 @@ MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
module.exports = MemoPost
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-25T23:18:27.154Z","module_hash":"139dec671f2f59aad3c83f5c225c1f8eb1f56e4f662f2b674d2f6db06bf4de8b","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":25,"end_line":28,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":32,"end_line":42,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":46,"end_line":69,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":72,"end_line":82,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":85,"end_line":93,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]}
// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]}
// mutate4javascript-manifest-end
+4
View File
@@ -81,3 +81,7 @@ NewPostPage.NEW_POST_PATH = NEW_POST_PATH
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
module.exports = NewPostPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T00:07:51.843Z","module_hash":"469dfd90342f6bcacf5b89ed819620663e221e20832f6936c35c46f9681ebfdc","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":33,"hash":"7c957fbaa2b8d4adb62c1bbf240749243696a68e71e7896aefdbb68437432cd8"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":47,"end_line":50,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":53,"end_line":55,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":59,"end_line":77,"hash":"f4ce743f5a4bb139615086165b25173641a9388af16b7527f2db243a1c6b596c"}]}
// mutate4javascript-manifest-end
+34
View File
@@ -88,6 +88,8 @@ test('posting a valid memo broadcasts the Memo post prefix and navigates to the
const result = await page.submit()
assert.equal(result.ok, true)
// The page returns to an idle (not posting) state after success.
assert.equal(page.posting, false)
// Broadcast happened with the Memo post prefix and the exact message.
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d02')
@@ -108,6 +110,7 @@ test('posting an empty memo is rejected with a validation error and nothing is b
assert.equal(result.ok, false)
assert.equal(result.error, 'memo_validation')
assert.equal(page.submitError, 'memo_validation')
assert.equal(page.posting, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(feed.posts.length, 0)
assert.deepEqual(navigations, [])
@@ -122,11 +125,42 @@ test('posting an over-long memo is rejected with a length error and nothing is b
assert.equal(result.ok, false)
assert.equal(result.error, 'memo_length')
assert.equal(page.submitError, 'memo_length')
assert.equal(page.posting, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(feed.posts.length, 0)
assert.deepEqual(navigations, [])
})
test('the new post page starts idle (not posting)', () => {
const { page } = build()
assert.equal(page.posting, false)
})
test('posting is true while a submit is in flight and false once it settles', async () => {
const wallet = fakeWallet()
const feed = fakeFeed()
// Defer the broadcast so we can observe the in-flight posting state.
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
const page = new NewPostPage({
memoPost: new MemoPost({ wallet, feed }),
navigate: () => {}
})
page.setInput('hello memo')
assert.equal(page.posting, false)
const pending = page.submit()
assert.equal(page.posting, true)
// Yield until the async chain reaches the deferred sendOpReturn call.
await new Promise((r) => setImmediate(r))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.posting, false)
})
test('submitting without a memo post handler reports an error and does not navigate', async () => {
const navigations = []
const page = new NewPostPage({ navigate: (p) => navigations.push(p) })