fix(engagement): the six defects the browser pass found (Phase 4b)
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m35s
PR Checks / bot-tests (pull_request) Successful in 8m34s

Driving the two screens in Chrome, after the API walk had already found the two
in Phase 4a's code. None of these is visible from a test or from curl.

Two cost an operator something real:

  - The Audience dropdown rendered EMPTY before a trigger was chosen. There is
    genuinely nothing it may offer without a ceiling, but a select with zero
    options reads as broken rather than as waiting. It now says "Choose a
    trigger first..." and is disabled.
  - A `members` audience with no saved audience reaches NOBODY, and only the
    preview button said so. That is the design, but it is also the default the
    instant a members-ceiling trigger is picked - so the rule saves, gets
    switched on, and mails nobody with nothing on screen saying so. The editor
    now says it inline, and stands down once a preview has answered the same
    question more precisely.

One the server was already refusing, just too late:

  - The composer offered "exclude" on the only row, building an `and` whose
    every child is a complement. The server refuses it correctly but only after
    a save, and it is one checkbox away at all times. Now refused inline, in the
    operator's words.

Three wording and layout:

  - the template-key input truncated its placeholder, and said "optional until
    Phase 5" - a sentence about the plan document, not about the deployment
  - "segment" leaked into a screen that says "saved audience" everywhere else.
    The API, schema and docs keep saying segment (one word for one table);
    translated at the point of display only
  - the composer repeated its AUDIENCE heading above every row

Client only - no server change, so swagger and the route manifest are untouched.
Client suite 316/316; all six verified in the browser after the fix.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:26:29 -05:00
parent 4b45eddb5d
commit 3a7a08425c
4 changed files with 105 additions and 8 deletions

View File

@@ -9,6 +9,8 @@ import {
describeRule,
describeExpression,
notPlacementError,
audienceWarning,
operatorWords,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
@@ -218,6 +220,34 @@ test('a single stored comparison is one editable row', () => {
// ── Segment composition ────────────────────────────────────────────────────
test('a members audience with no saved audience is warned about BEFORE the save', () => {
// The trap the browser walk found: it is the default the moment a
// members-ceiling trigger is chosen, and the rule it produces saves, switches
// on and mails nobody. Nothing on the screen said so unless you pressed
// Preview.
assert.match(audienceWarning({ audience: 'members', audienceSegmentId: null }), /reaches nobody/)
assert.equal(audienceWarning({ audience: 'members', audienceSegmentId: 4 }), null)
assert.equal(audienceWarning({ audience: 'owner', audienceSegmentId: null }), null)
})
test('the server says "segment"; the screens say "saved audience"', () => {
// One word for one table in the API, the schema and the docs. But an operator
// meets the concept under a heading that says "Audiences", and a sentence that
// switches vocabulary mid-screen reads as being about something else.
assert.equal(operatorWords('audience segment is dormant'), 'audience saved audience is dormant')
assert.match(describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }), /saved audience/)
// and it does not maul a word that merely contains it
assert.equal(operatorWords('segmented data'), 'segmented data')
})
test('a list of nothing but exclusions is refused before the round trip', () => {
// One checkbox away at all times, because the composer offers "exclude" on
// every row including the only one. The server refuses it correctly — but
// only after a save.
const err = notPlacementError({ op: 'and', nodes: [{ op: 'not', nodes: [{ audienceId: 'a' }] }] })
assert.match(err, /at least one audience/i)
})
test('a bare not is refused before it reaches the server', () => {
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))