Files
MindSift/internal/collab/ops_test.go
T
sandrodachandClaude Opus 5.5 aa397c18cd
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
  Enter in such a block adds a new block below. Delete, merge, indent and
  drag are not started when a involved block (or one in its branch) is
  locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
  Übernehmen (replaces the original or the title) and Verwerfen. Both check
  the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
  history events): typing is grouped per block until a 1 s pause, up to 200
  steps per tab; a step is skipped with a notice if someone else changed or
  holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
  30 s instead of 3 s until the next change, because the delay was decided
  before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
  e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
  freshly built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:44:27 +01:00

275 lines
10 KiB
Go

package collab
import (
"testing"
"time"
)
var (
anna = Actor{UserID: "u_anna", ClientID: "c_anna", Name: "Anna"}
ben = Actor{UserID: "u_ben", ClientID: "c_ben", Name: "Ben"}
)
func str(s string) *string { return &s }
type fixedLeases map[string]string
func (f fixedLeases) HolderOf(id string) (string, bool) { c, ok := f[id]; return c, ok }
// docWith builds a doc via real operations so versions and keys are realistic.
func docWith(t *testing.T, blocks ...[3]string) *Doc { // {id, parent, content}
t.Helper()
d := NewDoc()
keys := map[string]string{}
for i, b := range blocks {
k, _ := KeyBetween(keys[b[1]], "")
keys[b[1]] = k
_, res := Apply(d, Op{OpID: "seed" + string(rune('a'+i)), Kind: OpInsert, BlockID: b[0], ParentID: str(b[1]), OrderKey: k, Content: str(b[2])}, anna, Env{})
if res.Status != StatusApplied {
t.Fatalf("seed %v: %+v", b, res)
}
}
return d
}
func mustValid(t *testing.T, d *Doc) {
t.Helper()
if err := d.Validate(); err != nil {
t.Fatal(err)
}
}
func TestSetContentAppliesOnMatchingVersion(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Hallo"})
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Hallo Welt")}, anna, Env{})
if res.Status != StatusApplied || d.Blocks["a"].Content != "Hallo Welt" || d.Blocks["a"].Version != 2 {
t.Fatalf("got %+v, block %+v", res, d.Blocks["a"])
}
}
func TestStaleVersionCreatesConflictCopy(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Start"}, [3]string{"b", "", "Danach"})
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Anna")}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Ben")}, ben, Env{})
if res.Status != StatusConflict || res.ConflictBlockID == "" {
t.Fatalf("want conflict, got %+v", res)
}
if d.Blocks["a"].Content != "Von Anna" {
t.Fatal("first writer must keep the block")
}
flat := d.Flatten()
if len(flat) != 3 || flat[1].ID != res.ConflictBlockID || flat[1].Content != "Von Ben" || flat[2].ID != "b" {
t.Fatalf("conflict copy must sit directly below the block: %+v", flat)
}
if c := d.Blocks[res.ConflictBlockID].Props; c.ConflictOf != "a" || c.ConflictAuthor != "Ben" {
t.Fatalf("conflict props wrong: %+v", c)
}
mustValid(t, d)
}
func TestForeignLeaseCreatesConflictCopy(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Start"})
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Ben tippt")}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
if res.Status != StatusConflict || d.Blocks["a"].Content != "Start" {
t.Fatalf("lease holder must win: %+v", res)
}
// The lease holder itself may write
_, res = Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Anna tippt")}, anna, Env{Leases: fixedLeases{"a": "c_anna"}})
if res.Status != StatusApplied {
t.Fatalf("lease holder rejected: %+v", res)
}
}
func TestIdenticalContentIsNoConflict(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Gleich"})
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, ben, Env{})
if res.Status != StatusApplied || len(d.Blocks) != 1 {
t.Fatalf("same text must not produce a conflict copy: %+v", res)
}
}
func TestEditOnDeletedBlockRestoresIt(t *testing.T) {
d := docWith(t, [3]string{"p", "", "Eltern"}, [3]string{"c", "p", "Kind"})
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "c", BaseVersion: 1, Content: str("Spät getippt")}, ben, Env{})
if res.Status != StatusApplied {
t.Fatalf("got %+v", res)
}
c := d.Blocks["c"]
if c.Deleted || c.Content != "Spät getippt" || c.ParentID != "" {
t.Fatalf("block must be restored at the nearest live ancestor: %+v", c)
}
mustValid(t, d)
}
func TestDeleteIsBlockedByForeignLeaseInSubtree(t *testing.T) {
d := docWith(t, [3]string{"p", "", "Ast"}, [3]string{"c", "p", "Unterpunkt"})
_, res := Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{Leases: fixedLeases{"c": "c_ben"}})
if res.Status != StatusRejected || res.Reason != "locked" || d.Blocks["p"].Deleted {
t.Fatalf("got %+v", res)
}
_, res = Apply(d, Op{OpID: "2", Kind: OpDelete, BlockID: "p"}, anna, Env{})
if res.Status != StatusApplied || !d.Blocks["c"].Deleted {
t.Fatal("delete must tombstone the whole subtree")
}
}
func TestMoveRejectsCyclesAndLockedBlocks(t *testing.T) {
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
if _, res := Apply(d, Op{OpID: "1", Kind: OpMove, BlockID: "a", ParentID: str("b"), OrderKey: "a0"}, anna, Env{}); res.Reason != "cycle" {
t.Fatalf("want cycle, got %+v", res)
}
if _, res := Apply(d, Op{OpID: "2", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{Leases: fixedLeases{"b": "c_ben"}}); res.Reason != "locked" {
t.Fatalf("want locked, got %+v", res)
}
if _, res := Apply(d, Op{OpID: "3", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{}); res.Status != StatusApplied {
t.Fatalf("got %+v", res)
}
mustValid(t, d)
}
func TestInsertIntoDeletedParentGoesToLiveAncestor(t *testing.T) {
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "b"}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "n", ParentID: str("b"), OrderKey: "a0", Content: str("neu")}, ben, Env{})
if res.Status != StatusApplied || d.Blocks["n"].ParentID != "a" {
t.Fatalf("got %+v parent=%q", res, d.Blocks["n"].ParentID)
}
}
func TestInsertRejectsTakenIDs(t *testing.T) {
d := docWith(t, [3]string{"a", "", "A"})
if _, res := Apply(d, Op{OpID: "1", Kind: OpInsert, BlockID: "a", OrderKey: "a5"}, anna, Env{}); res.Reason != "exists" {
t.Fatalf("got %+v", res)
}
env := Env{IDTaken: func(id string) bool { return id == "other_doc_block" }}
if _, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "other_doc_block", OrderKey: "a5"}, anna, env); res.Reason != "exists" {
t.Fatalf("block IDs of other documents must be rejected: %+v", res)
}
}
func TestTitleConflictBecomesTopBlock(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Erster"})
Apply(d, Op{OpID: "1", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Anna")}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Ben")}, ben, Env{})
if res.Status != StatusConflict || d.Title != "Titel Anna" {
t.Fatalf("got %+v title=%q", res, d.Title)
}
if flat := d.Flatten(); flat[0].ID != res.ConflictBlockID || flat[0].Props.ConflictOf != "title" {
t.Fatalf("title conflict must be the first block: %+v", flat[0])
}
}
func TestSetPropsDoesNotBumpContentVersion(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Todo"})
Apply(d, Op{OpID: "1", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: new(bool)}}, anna, Env{})
yes := true
Apply(d, Op{OpID: "2", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: &yes}}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
if !d.Blocks["a"].Props.Checked || d.Blocks["a"].Version != 1 {
t.Fatalf("props must apply independently of text and leases: %+v", d.Blocks["a"])
}
}
// Replaying the effects of every operation on an empty doc must give exactly the same state.
func TestEffectsReplayToSameState(t *testing.T) {
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
replay := d.Clone()
ops := []struct {
op Op
a Actor
}{
{Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A2")}, anna},
{Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A3")}, ben},
{Op{OpID: "3", Kind: OpDelete, BlockID: "a"}, anna},
{Op{OpID: "4", Kind: OpSetContent, BlockID: "b", BaseVersion: 1, Content: str("B2")}, ben},
{Op{OpID: "5", Kind: OpSetTitle, Title: str("T")}, anna},
}
for _, o := range ops {
effects, _ := Apply(d, o.op, o.a, Env{})
for _, e := range effects {
replay.ApplyEffect(e)
}
}
if !docsEqual(d, replay) {
t.Fatal("replayed effects differ from server state")
}
}
func docsEqual(a, b *Doc) bool {
if a.Title != b.Title || a.TitleVersion != b.TitleVersion || len(a.Blocks) != len(b.Blocks) {
return false
}
for id, x := range a.Blocks {
y, ok := b.Blocks[id]
if !ok || *x != *y {
return false
}
}
return true
}
func TestLeaseManager(t *testing.T) {
now := time.Unix(1000, 0)
m := NewLeaseManager(func() time.Time { return now })
g, den := m.Hold("d", anna, []string{"a", "b"})
if len(g) != 2 || len(den) != 0 {
t.Fatalf("anna: granted=%v denied=%v", g, den)
}
if g, den = m.Hold("d", ben, []string{"a", "c"}); len(g) != 1 || den[0] != "a" {
t.Fatalf("ben: granted=%v denied=%v", g, den)
}
if others := m.Others("d", "c_ben"); len(others) != 2 || others[0].Name != "Anna" {
t.Fatalf("others: %+v", others)
}
now = now.Add(LeaseTTL + time.Second)
if g, _ = m.Hold("d", ben, []string{"a"}); len(g) != 1 {
t.Fatal("expired lease must be available")
}
m.ReleaseAllExcept("d", "c_ben", []string{"a"})
if _, held := m.View("d").HolderOf("c"); held {
t.Fatal("lease not in keep set must be released")
}
m.Touch("d", anna)
m.Touch("d", ben)
if n, people := m.Sessions("d"); n != 2 || len(people) != 2 {
t.Fatalf("sessions: %d %+v", n, people)
}
m.Leave("d", "c_ben")
if _, held := m.View("d").HolderOf("a"); held {
t.Fatal("leaving must drop the client's leases")
}
}
func TestNotifier(t *testing.T) {
n := NewNotifier()
ch := n.Changed("d")
select {
case <-ch:
t.Fatal("must not fire before Notify")
default:
}
n.Notify("d")
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("Notify must wake waiters")
}
}
func TestClearConflictMarking(t *testing.T) {
d := docWith(t, [3]string{"a", "", "Start"})
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A")}, anna, Env{})
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("B")}, ben, Env{})
yes := true
Apply(d, Op{OpID: "3", Kind: OpSetProps, BlockID: res.ConflictBlockID, Patch: &PropsPatch{ClearConflict: &yes}}, ben, Env{})
if p := d.Blocks[res.ConflictBlockID].Props; p.ConflictOf != "" || p.ConflictAuthor != "" {
t.Fatalf("conflict marking not cleared: %+v", p)
}
}