mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
38aa872864
* fix(web): debounce typed-input field saves to stop per-keystroke activity rows (BUG-1466)
Text / number / URL fields in FieldEditor wired oninput directly to
onchange, so every keystroke became an item PATCH and an activity row.
Typing `ui/editor/tiptap` into a `component` field produced a 30-step
keystroke chain in the audit metadata (visible on BUG-1419's timeline).
Wrap the typed-input branches in a 500ms idle debounce, flush on blur
so tabbing away commits immediately, and flush on unmount so navigation
never drops a pending value. Discrete inputs (select / date / checkbox,
number ±1 buttons) keep firing on the user action — they aren't typing.
Mirrors the markdown content debounce pattern in the detail page.
* fix(activity): collapse same-field runs in merged changes metadata + unify diff separator (BUG-1466)
Follow-up to the web-side typing debounce. Two related changes:
1) collapseChanges() walks the merged "; "-delimited changes string and
collapses runs of consecutive same-field entries into a single
"field: first-old → last-new". Drops net no-ops (typed then backspaced).
When the web-side debounce in FieldEditor still produces multiple
PATCHes within the 5-minute coalesce window — or for older rows that
pre-date the debounce — the timeline now reads as one transition
instead of a chain. Run-based (not global) collapse so interleaved
edits on different fields keep their chronology.
2) diffFields now joins entries with "; " instead of ", " so the joiner
is consistent with mergeActivityMeta and TimelineActivityCard.svelte's
split delimiter. Multi-field PATCHes previously rendered as a single
unparseable blob in the web timeline because the parser only split on
";" — fixed as a side-effect.
Adds TestCollapseChanges (10 cases including the BUG-1419 repro) and
TestMergeActivityMeta_CollapsesSameFieldRun. Updates the existing
TestDiffFieldsPrimitives expectation to match the new joiner.
* fix(fields,activity): two follow-ups per Codex review (round 1)
[P1] FieldEditor.svelte::handleNumberStep
The ±1 buttons computed `(Number(value) || 0) + delta` AFTER calling
flushPendingSave(). But `value` is the parent prop — flushing fires
onchange asynchronously, so at the moment of the step computation
the prop still holds the pre-typed value. Typing 10 over 5 and
clicking + would flush 10 then send 6, overwriting the typed value.
Compute `base` from `pendingValue` (if hasPending) BEFORE clearing
the timer state, then send `base + delta` in one onchange call.
[P2] activities.go::collapseChanges
The drop-net-no-op step removed entries where `from == to`. But
diffFields intentionally emits same-display entries for
same-cardinality structured-field replacements like
`implementation_notes: (1 note) → (1 note)` (see
TestDiffFieldsSameCardinalityArrayChangeStillReported) — the labels
match because formatChangeValue summarizes by count, not content,
but the underlying data did change. Dropping them silently hides
real updates from the activity feed.
Track `mergedCount` per entry: increment when collapsing a run,
initialize to 1 on parse. Only drop when `mergedCount > 1 && from == to`
— i.e. only when the no-op resulted from collapsing multiple input
segments (the typed-then-backspaced case).
Tests: 2 new TestCollapseChanges cases (single structured-field
preserved, interleaved structured-field around a typed run).
* fix(fields,activity): two follow-ups per Codex review (round 2)
[P1] FieldEditor.svelte number stepper focus race
The number ±1 buttons race with the input's onblur handler: blur
fires before click in the natural focus-transfer flow, so
flushPendingSave clears hasPending → handleNumberStep reads stale
`value` from the parent prop → typing 25 over 10 and clicking +
sends 25 then 11, losing the typed value.
Add onmousedown={preventDefault} on both ±1 buttons. Mousedown
precedes blur, and preventDefault on mousedown suppresses the
natural focus transfer — the input keeps focus through the click,
so hasPending survives until handleNumberStep reads it.
[P2] collapseChanges still drops repeated same-display structured runs
Round 1's mergedCount>1 rule still dropped runs like
`implementation_notes: (1 note) → (1 note); implementation_notes: (1 note) → (1 note)`
— two real updates whose display strings happen to match because
formatChangeValue summarises array-valued fields by count. Each
PATCH represented a different underlying note (diffFields uses
reflect.DeepEqual to detect that), but the merged display showed
no transition.
Track `hadTransition` per entry: true iff the run had a display-
level transition (initial from != to, or a subsequent entry's `to`
differed from the anchored from). Drop only when
mergedCount > 1 && from == to && hadTransition — i.e. only true
net-cancellations (typed-then-backspaced). Same-display structured
repeats stay; real `foo → bar → foo` swings still drop.
Tests: 2 new TestCollapseChanges cases (repeated same-display preserved,
real foo→bar→foo swing still dropped).
* fix(fields,activity): two follow-ups per Codex review (round 3)
[P1] FieldEditor cross-item leak via debounce timer
When the parent reuses a FieldEditor instance across an item swap
(same schema, same field.key, different item — common when
navigating between items in the same collection), the parent's
`updateField` closure reads `item.id` at CALL time. A pending
timer set while item A was active would fire after item B mounted,
patching B with A's typed value.
Two-pronged fix in FieldEditor:
- Add a $effect that tracks the `value` prop and drops any pending
save the moment the parent re-props us. The user actively typed
for a now-stale context; aborting is safer than silently writing
to the new context. Also covers external collab/SSE rebases of
the same field on the same item.
- Switch the unmount cleanup from flush → drop. When the parent
navigates to an item whose schema lacks this field, the
FieldEditor unmounts AFTER the parent's `item` has already been
replaced, so a final onchange call would route through
updateField → wrong item. Blur is the supported commit gesture
(clicking elsewhere within the page, ±1 buttons, tab-out — all
flush eagerly); unmount-without-blur is treated as "user
abandoned the edit."
[P2] collapseChanges still dropped structured count-return swings
Round 2's hadTransition rule still dropped a run like
`implementation_notes: (1 note) → (2 notes); implementation_notes: (2 notes) → (1 note)`
— the user added a note then removed the original, ending with a
different single note. The merged display reads `(1 note) → (1 note)`
with hadTransition=true, indistinguishable from a typed-then-
backspaced cancellation. But formatChangeValue summaries are
LOSSY: same display label can wrap entirely different raw values.
Add a `hasLossySummary` flag per entry — true iff either `from`
or `to` matches the `(text)` format formatChangeValue emits for
structured fields. Propagate stickily across the run (any lossy
entry pins the whole run). Skip the drop step when the run
carries lossy summaries — we can't recover the raw delta from the
merged string, so we preserve the entry rather than risk hiding
a real change.
Tests: 2 new TestCollapseChanges cases (count-return swing
preserved, lossy-on-one-side still pins the run).
* fix(fields): demote hasPending from \$state to plain let — \$effect was cancelling every keystroke (round 4 [P1])
Critical regression in round 3: the value-track \$effect read
\`hasPending\` inside its body, which Svelte 5 promotes to a reactive
dependency. scheduleSave() setting \`hasPending = true\` retriggered
the same \$effect, whose body cleared the typing timer + pendingValue
+ hasPending before the debounce could fire. Net effect: typing into
any text / number / URL field was silently dropped — onchange never
ran, the field never saved.
hasPending is only read from imperative handlers (scheduleSave,
flushPendingSave, handleNumberStep, the value-track \$effect, the
unmount cleanup) — never from a template or other reactive context.
Demoting it to a plain \`let\` removes the unwanted subscription while
preserving the round-3 behaviour: external value-prop changes still
trigger the \$effect (it tracks \`value\`), the body reads hasPending
imperatively to decide whether to clear pending state.
No tests added — this is a Svelte reactivity edge case that can't
be unit-tested without a DOM. svelte-autofixer's pre-existing
"variable assigned inside \$effect" suggestion previously flagged the
hasPending mutation; that signal is gone now.
Per Codex review round 4.
168 lines
6.4 KiB
Go
168 lines
6.4 KiB
Go
package server
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDiffFieldsPrimitives(t *testing.T) {
|
|
got := diffFields(
|
|
`{"status":"open","priority":"medium"}`,
|
|
`{"status":"in-progress","priority":"high"}`,
|
|
)
|
|
// Order is alphabetical (sort.Strings on the changes slice). "; " is
|
|
// the joiner; matches mergeActivityMeta and the web parser's split
|
|
// delimiter so multi-field PATCHes render as individual change pills
|
|
// instead of one unparseable blob.
|
|
want := "priority: medium → high; status: open → in-progress"
|
|
if got != want {
|
|
t.Errorf("diffFields primitives:\n got: %q\n want: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsAddedRemovedFields(t *testing.T) {
|
|
// Newly added field — old map missing the key.
|
|
got := diffFields(`{"status":"open"}`, `{"status":"open","priority":"high"}`)
|
|
want := "priority: → high"
|
|
if got != want {
|
|
t.Errorf("diffFields added:\n got: %q\n want: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsImplementationNotesSummarised(t *testing.T) {
|
|
// Add the first implementation note. Without the structured-field
|
|
// formatting this used to render as
|
|
// `implementation_notes: → [map[created_at:... details:... summary:...]]`
|
|
// which is the BUG-748 activity-card regression we're guarding against.
|
|
old := `{"status":"open"}`
|
|
updated := `{"status":"open","implementation_notes":[{"id":"n1","summary":"Phases 1-3 verified shipped","details":"Code audit on 2026-04-23 found Phase 1, 2, and most of Phase 3 already implemented","created_at":"2026-04-23T00:49:04Z","created_by":"user"}]}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "implementation_notes: → (1 note)"
|
|
if got != want {
|
|
t.Errorf("diffFields implementation_notes single:\n got: %q\n want: %q", got, want)
|
|
}
|
|
|
|
// Confirm the raw map repr never leaks through.
|
|
if strings.Contains(got, "map[") || strings.Contains(got, "details:") {
|
|
t.Errorf("diffFields leaked Go map repr: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsImplementationNotesPluralised(t *testing.T) {
|
|
old := `{"implementation_notes":[{"summary":"first"}]}`
|
|
updated := `{"implementation_notes":[{"summary":"first"},{"summary":"second"},{"summary":"third"}]}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "implementation_notes: (1 note) → (3 notes)"
|
|
if got != want {
|
|
t.Errorf("diffFields implementation_notes count change:\n got: %q\n want: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsDecisionLogSummarised(t *testing.T) {
|
|
old := `{"status":"active"}`
|
|
updated := `{"status":"active","decision_log":[{"id":"d1","decision":"Store notes in reserved field keys","rationale":"Avoid a new table"}]}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "decision_log: → (1 entry)"
|
|
if got != want {
|
|
t.Errorf("diffFields decision_log single:\n got: %q\n want: %q", got, want)
|
|
}
|
|
|
|
old2 := `{"decision_log":[{"decision":"first"}]}`
|
|
updated2 := `{"decision_log":[{"decision":"first"},{"decision":"second"}]}`
|
|
got2 := diffFields(old2, updated2)
|
|
want2 := "decision_log: (1 entry) → (2 entries)"
|
|
if got2 != want2 {
|
|
t.Errorf("diffFields decision_log count change:\n got: %q\n want: %q", got2, want2)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsGenericStructuredFieldsFallback(t *testing.T) {
|
|
// An unknown array-of-objects field should still produce a summary,
|
|
// not a raw map repr.
|
|
old := `{"status":"open"}`
|
|
updated := `{"status":"open","custom_attachments":[{"name":"a.png"},{"name":"b.png"}]}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "custom_attachments: → (2 items)"
|
|
if got != want {
|
|
t.Errorf("diffFields generic structured field:\n got: %q\n want: %q", got, want)
|
|
}
|
|
if strings.Contains(got, "map[") {
|
|
t.Errorf("diffFields generic structured field leaked Go map repr: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsObjectFieldFallback(t *testing.T) {
|
|
// A bare object field — also previously dumped as `map[k:v]`.
|
|
old := `{"status":"open"}`
|
|
updated := `{"status":"open","convention":{"trigger":"on-implement","scope":"all"}}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "convention: → (object)"
|
|
if got != want {
|
|
t.Errorf("diffFields object field:\n got: %q\n want: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDiffFieldsHandlesInvalidJSON(t *testing.T) {
|
|
if got := diffFields("not-json", `{"a":1}`); got != "" {
|
|
t.Errorf("diffFields invalid old: expected empty, got %q", got)
|
|
}
|
|
if got := diffFields(`{"a":1}`, "not-json"); got != "" {
|
|
t.Errorf("diffFields invalid new: expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatChangeValueNilSafe(t *testing.T) {
|
|
if got := formatChangeValue("status", nil); got != "" {
|
|
t.Errorf("formatChangeValue(nil): expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Regression for the Codex-round-1 finding on PR #236: collapsing slice
|
|
// values to a count-only label meant a same-cardinality replacement (one
|
|
// note swapped for a different one note) produced equal old/new display
|
|
// strings, so `diffFields()` silently dropped the change. The fix compares
|
|
// the decoded values via reflect.DeepEqual instead of the display strings,
|
|
// so a content change still produces a metadata.changes entry. The display
|
|
// string is still the friendly summary (`(1 note) → (1 note)`) — informative
|
|
// but coarse — which is acceptable: the activity card also shows the actor
|
|
// + timestamp, so the user knows something changed and can drill in.
|
|
func TestDiffFieldsSameCardinalityArrayChangeStillReported(t *testing.T) {
|
|
old := `{"implementation_notes":[{"id":"n1","summary":"original"}]}`
|
|
updated := `{"implementation_notes":[{"id":"n1","summary":"revised"}]}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "implementation_notes: (1 note) → (1 note)"
|
|
if got != want {
|
|
t.Errorf("diffFields same-cardinality replacement:\n got: %q\n want: %q", got, want)
|
|
}
|
|
|
|
// Also verify same JSON on both sides produces no entry (true no-op).
|
|
if got := diffFields(old, old); got != "" {
|
|
t.Errorf("diffFields no-op: expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Regression for the Codex-round-1 finding on PR #236: object-valued fields
|
|
// (e.g. `convention`) now both stringify to `(object)`, so an in-place edit
|
|
// would have been silently dropped. Confirm DeepEqual catches it.
|
|
func TestDiffFieldsObjectMutationStillReported(t *testing.T) {
|
|
old := `{"convention":{"trigger":"on-implement","scope":"all"}}`
|
|
updated := `{"convention":{"trigger":"on-commit","scope":"all"}}`
|
|
|
|
got := diffFields(old, updated)
|
|
want := "convention: (object) → (object)"
|
|
if got != want {
|
|
t.Errorf("diffFields object mutation:\n got: %q\n want: %q", got, want)
|
|
}
|
|
|
|
// Identical object — no-op, no entry.
|
|
if got := diffFields(old, old); got != "" {
|
|
t.Errorf("diffFields object no-op: expected empty, got %q", got)
|
|
}
|
|
}
|