···1919 unregister chan *Client
2020 mu sync.RWMutex
2121 ot *OTEngine
2222+ seeded bool // true after SeedText has been called
2223}
23242425// broadcastMsg carries a message and an optional sender to exclude.
···177178 }
178179 r.Broadcast(data)
179180}
181181+182182+// IsNewRoom returns true if SeedText has not yet been called on this room.
183183+func (r *Room) IsNewRoom() bool {
184184+ r.mu.RLock()
185185+ defer r.mu.RUnlock()
186186+ return !r.seeded
187187+}
188188+189189+// SeedText sets the initial document text for the OT engine.
190190+// Idempotent — only the first call has effect.
191191+func (r *Room) SeedText(text string) {
192192+ r.mu.Lock()
193193+ defer r.mu.Unlock()
194194+ if !r.seeded {
195195+ r.ot.SetText(text)
196196+ r.seeded = true
197197+ }
198198+}
+27
internal/collaboration/hub_test.go
···399399 }
400400}
401401402402+// --- SeedText / IsNewRoom ---
403403+404404+func TestRoom_SeedText_SetsInitialOTState(t *testing.T) {
405405+ hub := NewHub()
406406+ room := hub.GetOrCreateRoom("doc-seed")
407407+ if !room.IsNewRoom() {
408408+ t.Fatal("new room should report IsNewRoom=true")
409409+ }
410410+ room.SeedText("initial document content")
411411+ if room.IsNewRoom() {
412412+ t.Error("IsNewRoom should be false after seeding")
413413+ }
414414+ if got := room.ot.GetText(); got != "initial document content" {
415415+ t.Errorf("OT text after seed: got %q, want %q", got, "initial document content")
416416+ }
417417+}
418418+419419+func TestRoom_SeedText_IdempotentAfterFirstCall(t *testing.T) {
420420+ hub := NewHub()
421421+ room := hub.GetOrCreateRoom("doc-seed-idem")
422422+ room.SeedText("first")
423423+ room.SeedText("second") // should be ignored
424424+ if got := room.ot.GetText(); got != "first" {
425425+ t.Errorf("second SeedText should be ignored: got %q, want %q", got, "first")
426426+ }
427427+}
428428+402429// --- GetPresence ---
403430404431func TestRoom_GetPresence_Empty(t *testing.T) {
+13-3
internal/collaboration/ot.go
···4444}
45454646func (ot *OTEngine) applyInternal(op Operation) (string, bool) {
4747- if op.Insert == "" {
4848- return ot.documentText, false
4949- }
5047 if op.From < 0 {
5148 op.From = 0
5249 }
···6158 op.To = len(ot.documentText)
6259 }
6360 if op.From > op.To {
6161+ return ot.documentText, false
6262+ }
6363+6464+ // True no-op: nothing deleted and nothing inserted.
6565+ if op.From == op.To && op.Insert == "" {
6466 return ot.documentText, false
6567 }
6668···6971 ot.version++
70727173 return newText, true
7474+}
7575+7676+// SetText replaces the canonical document text without incrementing the version.
7777+// Call this once when a room is first created, before any edits are applied.
7878+func (ot *OTEngine) SetText(text string) {
7979+ ot.mu.Lock()
8080+ defer ot.mu.Unlock()
8181+ ot.documentText = text
7282}
73837484func (ot *OTEngine) GetText() string {
+165
internal/collaboration/ot_test.go
···11+package collaboration
22+33+import (
44+ "sync"
55+ "testing"
66+)
77+88+func TestOTEngine_NewEngine(t *testing.T) {
99+ ot := NewOTEngine("hello world")
1010+ if ot.GetText() != "hello world" {
1111+ t.Errorf("expected initial text %q, got %q", "hello world", ot.GetText())
1212+ }
1313+ if ot.GetVersion() != 0 {
1414+ t.Errorf("expected initial version 0, got %d", ot.GetVersion())
1515+ }
1616+}
1717+1818+func TestOTEngine_Apply_Insert(t *testing.T) {
1919+ ot := NewOTEngine("hello world")
2020+ result := ot.Apply(Operation{From: 5, To: 5, Insert: " beautiful"})
2121+ want := "hello beautiful world"
2222+ if result != want {
2323+ t.Errorf("Apply insert: got %q, want %q", result, want)
2424+ }
2525+}
2626+2727+func TestOTEngine_Apply_Replace(t *testing.T) {
2828+ ot := NewOTEngine("hello world")
2929+ result := ot.Apply(Operation{From: 6, To: 11, Insert: "Go"})
3030+ want := "hello Go"
3131+ if result != want {
3232+ t.Errorf("Apply replace: got %q, want %q", result, want)
3333+ }
3434+}
3535+3636+func TestOTEngine_Apply_Delete(t *testing.T) {
3737+ ot := NewOTEngine("hello world")
3838+ // Delete " world" by replacing with empty string.
3939+ result := ot.Apply(Operation{From: 5, To: 11, Insert: ""})
4040+ want := "hello"
4141+ if result != want {
4242+ t.Errorf("Apply delete (empty insert): got %q, want %q", result, want)
4343+ }
4444+}
4545+4646+func TestOTEngine_Apply_DeleteIncrementsVersion(t *testing.T) {
4747+ ot := NewOTEngine("hello world")
4848+ ot.Apply(Operation{From: 5, To: 11, Insert: ""})
4949+ if ot.GetVersion() != 1 {
5050+ t.Errorf("delete should increment version: got %d, want 1", ot.GetVersion())
5151+ }
5252+}
5353+5454+func TestOTEngine_Apply_EmptyInsertAtSamePosition_IsNoOp(t *testing.T) {
5555+ ot := NewOTEngine("hello")
5656+ result := ot.Apply(Operation{From: 3, To: 3, Insert: ""})
5757+ if result != "hello" {
5858+ t.Errorf("From==To and Insert==\"\" should be no-op: got %q", result)
5959+ }
6060+ if ot.GetVersion() != 0 {
6161+ t.Errorf("no-op should not increment version: got %d", ot.GetVersion())
6262+ }
6363+}
6464+6565+func TestOTEngine_Apply_FullReplace(t *testing.T) {
6666+ ot := NewOTEngine("old text")
6767+ result := ot.Apply(Operation{From: 0, To: -1, Insert: "brand new content"})
6868+ want := "brand new content"
6969+ if result != want {
7070+ t.Errorf("Apply full replace (To=-1): got %q, want %q", result, want)
7171+ }
7272+}
7373+7474+func TestOTEngine_Apply_FromBeyondEnd_Clamps(t *testing.T) {
7575+ ot := NewOTEngine("hi")
7676+ // From beyond length should be clamped to len(text)
7777+ result := ot.Apply(Operation{From: 100, To: 200, Insert: "!"})
7878+ want := "hi!"
7979+ if result != want {
8080+ t.Errorf("Apply clamped From: got %q, want %q", result, want)
8181+ }
8282+}
8383+8484+func TestOTEngine_Apply_NegativeFrom_Clamps(t *testing.T) {
8585+ ot := NewOTEngine("hello")
8686+ result := ot.Apply(Operation{From: -5, To: 0, Insert: ">> "})
8787+ want := ">> hello"
8888+ if result != want {
8989+ t.Errorf("Apply negative From: got %q, want %q", result, want)
9090+ }
9191+}
9292+9393+func TestOTEngine_Apply_FromGreaterThanTo_NoOp(t *testing.T) {
9494+ ot := NewOTEngine("hello")
9595+ result := ot.Apply(Operation{From: 3, To: 1, Insert: "X"})
9696+ // From > To → no-op
9797+ if result != "hello" {
9898+ t.Errorf("Apply From>To should be no-op: got %q, want %q", result, "hello")
9999+ }
100100+}
101101+102102+func TestOTEngine_VersionIncrements(t *testing.T) {
103103+ ot := NewOTEngine("abc")
104104+ if ot.GetVersion() != 0 {
105105+ t.Fatalf("initial version should be 0")
106106+ }
107107+ ot.Apply(Operation{From: 0, To: 0, Insert: "X"})
108108+ if ot.GetVersion() != 1 {
109109+ t.Errorf("version after apply: got %d, want 1", ot.GetVersion())
110110+ }
111111+ ot.Apply(Operation{From: 0, To: 0, Insert: "Y"})
112112+ if ot.GetVersion() != 2 {
113113+ t.Errorf("version after second apply: got %d, want 2", ot.GetVersion())
114114+ }
115115+}
116116+117117+func TestOTEngine_NoOpDoesNotIncrementVersion(t *testing.T) {
118118+ ot := NewOTEngine("abc")
119119+ ot.Apply(Operation{From: 1, To: 1, Insert: ""}) // no-op
120120+ if ot.GetVersion() != 0 {
121121+ t.Errorf("no-op should not increment version: got %d, want 0", ot.GetVersion())
122122+ }
123123+}
124124+125125+func TestOTEngine_ApplyWithVersion(t *testing.T) {
126126+ ot := NewOTEngine("hello")
127127+ text, ver := ot.ApplyWithVersion(Operation{From: 5, To: 5, Insert: "!"})
128128+ if text != "hello!" {
129129+ t.Errorf("ApplyWithVersion text: got %q, want %q", text, "hello!")
130130+ }
131131+ if ver != 1 {
132132+ t.Errorf("ApplyWithVersion version: got %d, want 1", ver)
133133+ }
134134+}
135135+136136+func TestOTEngine_SetText(t *testing.T) {
137137+ ot := NewOTEngine("")
138138+ ot.SetText("initial content")
139139+ if ot.GetText() != "initial content" {
140140+ t.Errorf("SetText: got %q, want %q", ot.GetText(), "initial content")
141141+ }
142142+ // SetText should not change the version counter.
143143+ if ot.GetVersion() != 0 {
144144+ t.Errorf("SetText should not increment version: got %d", ot.GetVersion())
145145+ }
146146+}
147147+148148+func TestOTEngine_ConcurrentApply_DataRace(t *testing.T) {
149149+ // Run with -race to catch races. This test just verifies no panic / race.
150150+ ot := NewOTEngine("start")
151151+ var wg sync.WaitGroup
152152+ for i := 0; i < 20; i++ {
153153+ wg.Add(1)
154154+ go func() {
155155+ defer wg.Done()
156156+ ot.Apply(Operation{From: 0, To: 0, Insert: "x"})
157157+ }()
158158+ }
159159+ wg.Wait()
160160+ // After 20 inserts of "x" at position 0, we have 20 extra chars
161161+ text := ot.GetText()
162162+ if len(text) != len("start")+20 {
163163+ t.Errorf("concurrent apply: expected length %d, got %d (text=%q)", len("start")+20, len(text), text)
164164+ }
165165+}