| 1 | package movementtest |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | movement "amadan.net/carlos/movement/internal/movement" |
| 11 | ) |
| 12 | |
| 13 | // The copy in this app is reviewed by Paul, string by string, and applied |
| 14 | // verbatim. This test is what stops it drifting afterwards. |
| 15 | // |
| 16 | // Without it, "applied verbatim" is a claim about one afternoon. A later |
| 17 | // session fixing a typo, tightening a sentence or making a heading |
| 18 | // "consistent" would be quietly overwriting somebody's reviewed words, and |
| 19 | // nothing would notice. copy-review/result.json is the record of what was |
| 20 | // approved, so it is the thing to compare against. |
| 21 | |
| 22 | // The durable record of what has been approved. NOT result.json: the review |
| 23 | // server deletes that when a new round starts, so a test pointed at it loses |
| 24 | // its guard the moment somebody opens another round — which is exactly when |
| 25 | // copy is most likely to move. approved.json is written from a finished |
| 26 | // round and outlives the next one. |
| 27 | const reviewResult = "../../copy-review/approved.json" |
| 28 | |
| 29 | type reviewedString struct { |
| 30 | ID string `json:"id"` |
| 31 | Text string `json:"text"` |
| 32 | } |
| 33 | |
| 34 | type reviewOutcome struct { |
| 35 | Action string `json:"action"` |
| 36 | Strings []reviewedString `json:"strings"` |
| 37 | } |
| 38 | |
| 39 | // notInTheTable are ids that went through review but deliberately do not |
| 40 | // appear in the copy table. Each one needs a reason here, so that dropping a |
| 41 | // reviewed string is a decision on the record rather than an omission. |
| 42 | var notInTheTable = map[string]string{ |
| 43 | // Paul, after submitting: "Lose the JS required message ... that's an |
| 44 | // irrelevant implementation detail." The form still needs JavaScript; the |
| 45 | // page no longer explains why. |
| 46 | "form.nojs": "removed on request after review", |
| 47 | |
| 48 | // These three are web addresses rather than words on a page. They are |
| 49 | // reviewed copy, but they live in the router. |
| 50 | "slug.never": "routing, not display copy", |
| 51 | "slug.curious": "routing, not display copy", |
| 52 | "slug.enthusiast": "routing, not display copy", |
| 53 | } |
| 54 | |
| 55 | // One submitted string opened "This was entirely AI-generated. Human editor |
| 56 | // leaving as-is:" and it was queried, because it reads like a note to whoever |
| 57 | // is applying the copy. It is not. Paul confirmed on 2026-09-02 that it is |
| 58 | // the joke: the people who said they are all in on AI get the model's output |
| 59 | // verbatim. |
| 60 | // |
| 61 | // On 2026-09-03 he moved it: the disclosure now sits AFTER the text it |
| 62 | // describes, reworded, as its own string awaiting its own review. So the |
| 63 | // message shipped is the submitted text minus that prefix, which is the one |
| 64 | // place this file allows a difference — and it names it rather than |
| 65 | // tolerating a mismatch quietly. |
| 66 | |
| 67 | // changedAfterReview records strings the user directed a change to after |
| 68 | // approving them. Each needs a date and what they asked for, so that a |
| 69 | // difference from the review record is always a decision somebody made and |
| 70 | // never drift nobody noticed. |
| 71 | var changedAfterReview = map[string]string{ |
| 72 | "stance.enthusiast.message": "2026-09-03: Paul moved the AI disclosure " + |
| 73 | "below the text it describes, so the prefix left this string and " + |
| 74 | "became stance.enthusiast.disclosure, which awaits its own review.", |
| 75 | } |
| 76 | |
| 77 | // pendingAgain is computed from the copy table itself rather than listed by |
| 78 | // hand: anything the app still marks as a placeholder is mid-round, whatever |
| 79 | // it was approved as previously. |
| 80 | var pendingAgain = func() map[string]bool { |
| 81 | m := map[string]bool{} |
| 82 | for _, id := range movement.Placeholders() { |
| 83 | m[id] = true |
| 84 | } |
| 85 | return m |
| 86 | }() |
| 87 | |
| 88 | func TestEveryStringMatchesTheApprovedReview(t *testing.T) { |
| 89 | raw, err := os.ReadFile(filepath.FromSlash(reviewResult)) |
| 90 | if err != nil { |
| 91 | t.Fatalf("no review record to check against: %v", err) |
| 92 | } |
| 93 | var out reviewOutcome |
| 94 | if err := json.Unmarshal(raw, &out); err != nil { |
| 95 | t.Fatalf("unreadable review record: %v", err) |
| 96 | } |
| 97 | if out.Action != "approve" { |
| 98 | t.Fatalf("the review record says %q, not approve — nothing should have shipped", out.Action) |
| 99 | } |
| 100 | |
| 101 | checked := 0 |
| 102 | for _, s := range out.Strings { |
| 103 | if why, skipped := notInTheTable[s.ID]; skipped { |
| 104 | t.Logf("%s: not in the table (%s)", s.ID, why) |
| 105 | continue |
| 106 | } |
| 107 | |
| 108 | // A string that has been re-marked as a placeholder is awaiting a new |
| 109 | // round, so the old approved text is no longer what it should equal. |
| 110 | // Production still refuses to serve it, which is the real guard. |
| 111 | if pendingAgain[s.ID] { |
| 112 | t.Logf("%s: re-opened for review, not compared", s.ID) |
| 113 | continue |
| 114 | } |
| 115 | |
| 116 | if why, changed := changedAfterReview[s.ID]; changed { |
| 117 | // Still checked, but against what the change left behind: the |
| 118 | // submitted text with the moved prefix removed. |
| 119 | const prefix = "This was entirely AI-generated. Human editor leaving as-is:" |
| 120 | rest, found := strings.CutPrefix(s.Text, prefix) |
| 121 | if !found { |
| 122 | t.Errorf("%s is recorded as changed after review (%s) but no "+ |
| 123 | "longer matches the shape that change expected", s.ID, why) |
| 124 | continue |
| 125 | } |
| 126 | if got := movement.T(s.ID); got != strings.TrimLeft(rest, "\n ") { |
| 127 | t.Errorf("%s differs from the approved text by more than the "+ |
| 128 | "change on record (%s).\n approved: %q\n shipped : %q", |
| 129 | s.ID, why, s.Text, got) |
| 130 | } |
| 131 | checked++ |
| 132 | continue |
| 133 | } |
| 134 | |
| 135 | want := s.Text |
| 136 | got := movement.T(s.ID) |
| 137 | if got != want { |
| 138 | t.Errorf("%s does not match what was approved.\n approved: %q\n shipped : %q", |
| 139 | s.ID, want, got) |
| 140 | } |
| 141 | checked++ |
| 142 | } |
| 143 | |
| 144 | if checked == 0 { |
| 145 | t.Fatal("the review record matched nothing; the ids have drifted apart") |
| 146 | } |
| 147 | t.Logf("%d reviewed strings match", checked) |
| 148 | } |
| 149 | |
| 150 | // A string in the table that nobody reviewed is the failure this whole gate |
| 151 | // exists to prevent, so it is worth catching directly rather than trusting |
| 152 | // that the next person remembers. |
| 153 | func TestNoStringShippedWithoutReview(t *testing.T) { |
| 154 | raw, err := os.ReadFile(filepath.FromSlash(reviewResult)) |
| 155 | if err != nil { |
| 156 | t.Fatalf("no review record: %v", err) |
| 157 | } |
| 158 | var out reviewOutcome |
| 159 | if err := json.Unmarshal(raw, &out); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | |
| 163 | reviewed := map[string]bool{} |
| 164 | for _, s := range out.Strings { |
| 165 | reviewed[s.ID] = true |
| 166 | } |
| 167 | |
| 168 | // A string still marked as a placeholder has not shipped: the app |
| 169 | // refuses to serve the canonical host while any remain, which is the |
| 170 | // mechanism that lets design work run ahead of the next copy round. |
| 171 | pending := map[string]bool{} |
| 172 | for _, id := range movement.Placeholders() { |
| 173 | pending[id] = true |
| 174 | } |
| 175 | |
| 176 | for _, id := range movement.CopyIDs() { |
| 177 | if !reviewed[id] && !pending[id] { |
| 178 | t.Errorf("%q is in the copy table, was never reviewed, and is not "+ |
| 179 | "marked as a placeholder — so it would ship unreviewed", id) |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |