1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 acknowledgeComposerNativeEvent,
5 assumeComposerControlledState,
6 isComposerNativeEcho,
7 pruneAcknowledgedComposerNativeEvents,
8 resolveComposerControlledEventCount,
9} from "./composerEditorRevision";
10
11describe("acknowledgeComposerNativeEvent", () => {
12 it("advances to newer native text revisions", () => {
13 expect(acknowledgeComposerNativeEvent(4, 5)).toBe(5);
14 });
15
16 it("accepts a duplicate event from the current native revision", () => {
17 expect(acknowledgeComposerNativeEvent(5, 5)).toBe(5);
18 });
19
20 it("rejects events older than the latest native text revision", () => {
21 expect(acknowledgeComposerNativeEvent(5, 4)).toBeNull();
22 });
23
24 it("rejects malformed revision counters", () => {
25 expect(acknowledgeComposerNativeEvent(5, Number.NaN)).toBeNull();
26 expect(acknowledgeComposerNativeEvent(5, 5.5)).toBeNull();
27 });
28});
29
30describe("isComposerNativeEcho", () => {
31 const snapshots = [{ eventCount: 3, value: "native", selection: { start: 6, end: 6 } }];
32
33 it("matches the exact native text revision and selection", () => {
34 expect(isComposerNativeEcho("native", { start: 6, end: 6 }, 3, snapshots)).toBe(true);
35 });
36
37 it("does not claim parent-driven selection or repeated-text updates", () => {
38 expect(isComposerNativeEcho("native", { start: 2, end: 2 }, 3, snapshots)).toBe(false);
39 expect(isComposerNativeEcho("native", { start: 6, end: 6 }, 4, snapshots)).toBe(false);
40 expect(isComposerNativeEcho("parent edit", { start: 6, end: 6 }, 3, snapshots)).toBe(false);
41 });
42
43 it("matches value and revision when selection is uncontrolled", () => {
44 expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true);
45 });
46
47 it("does not claim a controlled selection against an assumed state without one", () => {
48 // An echo payload serializes `selection: null`; classifying a controlled
49 // selection as an echo of an assumed state would drop a parent caret move.
50 const assumed = [{ eventCount: 3, value: "native", selection: null }];
51 expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false);
52 expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false);
53 });
54
55 it("matches an assumed state when selection is uncontrolled", () => {
56 const assumed = [{ eventCount: 3, value: "native", selection: null }];
57 expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true);
58 });
59});
60
61describe("resolveComposerControlledEventCount", () => {
62 const snapshots = [
63 { eventCount: 0, value: "", selection: { start: 0, end: 0 } },
64 { eventCount: 2, value: "a", selection: { start: 1, end: 1 } },
65 { eventCount: 4, value: "ab", selection: { start: 2, end: 2 } },
66 ];
67
68 it("tags a delayed parent value with the native revision that produced it", () => {
69 expect(resolveComposerControlledEventCount("a", { start: 1, end: 1 }, 4, snapshots)).toBe(2);
70 });
71
72 it("does not acknowledge the pre-edit parent value as the latest revision", () => {
73 expect(resolveComposerControlledEventCount("", { start: 0, end: 0 }, 4, snapshots)).toBe(0);
74 });
75
76 it("acknowledges the latest native value at the latest revision", () => {
77 expect(resolveComposerControlledEventCount("ab", { start: 2, end: 2 }, 4, snapshots)).toBe(4);
78 });
79
80 it("allows an unmatched parent-driven edit at the latest native revision", () => {
81 expect(resolveComposerControlledEventCount("/plan ", { start: 6, end: 6 }, 4, snapshots)).toBe(
82 4,
83 );
84 });
85
86 it("uses the newest revision when selection events repeat the same value", () => {
87 expect(
88 resolveComposerControlledEventCount("ab", { start: 1, end: 1 }, 5, [
89 ...snapshots,
90 { eventCount: 5, value: "ab", selection: { start: 1, end: 1 } },
91 ]),
92 ).toBe(5);
93 });
94
95 it("keeps a stale selection paired with current text behind the native revision", () => {
96 expect(resolveComposerControlledEventCount("ab", { start: 1, end: 1 }, 4, snapshots)).toBe(3);
97 });
98
99 it("does not control selection when no selection prop is provided", () => {
100 expect(resolveComposerControlledEventCount("ab", null, 4, snapshots)).toBe(4);
101 });
102});
103
104describe("pruneAcknowledgedComposerNativeEvents", () => {
105 it("releases an arbitrarily long acknowledged backlog without a fixed-size cliff", () => {
106 const snapshots = Array.from({ length: 1_000 }, (_, eventCount) => ({
107 eventCount,
108 value: `value-${eventCount}`,
109 selection: { start: eventCount, end: eventCount },
110 }));
111
112 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]);
113 });
114
115 it("retains native events that arrive after the acknowledged render", () => {
116 const snapshots = [
117 { eventCount: 40, value: "a", selection: { start: 1, end: 1 } },
118 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
119 ];
120
121 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots);
122 });
123
124 it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => {
125 const snapshots = [
126 { eventCount: 40, value: "a", selection: { start: 1, end: 1 } },
127 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
128 { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } },
129 ];
130
131 const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42);
132 expect(pruned).toEqual([snapshots[2]]);
133 expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true);
134 });
135
136 it("keeps the newest of several snapshots sharing the acknowledged revision", () => {
137 const snapshots = [
138 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
139 { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } },
140 ];
141
142 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]);
143 });
144});
145
146describe("assumeComposerControlledState", () => {
147 it("replaces the acknowledged history with the applied controlled state", () => {
148 const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }];
149
150 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
151 { eventCount: 3, value: "", selection: null },
152 ]);
153 });
154
155 it("keeps native events that raced past the controlled revision", () => {
156 const snapshots = [
157 { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } },
158 { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } },
159 ];
160
161 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
162 { eventCount: 3, value: "", selection: null },
163 snapshots[1],
164 ]);
165 });
166
167 it("applies a parent caret move on the assumed value at the assumed revision", () => {
168 // Same value, new caret: not an echo (so the selection is serialized) but
169 // still stamped at the assumed revision so the editor accepts it.
170 const snapshots = assumeComposerControlledState([], 3, "typed");
171
172 expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false);
173 expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(
174 3,
175 );
176 });
177
178 it("re-applies a parent value that round-trips back to an acknowledged state", () => {
179 // Native acknowledged "typed", the parent then controlled the editor to ""
180 // (a send clearing the draft) and back to "typed" (the send failed and the
181 // draft was restored). The restore must be a fresh non-echo edit stamped at
182 // the current revision, not an echo the editor would drop.
183 const snapshots = assumeComposerControlledState(
184 [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }],
185 3,
186 "",
187 );
188
189 expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false);
190 expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(
191 3,
192 );
193 });
194});
195
196describe("typing immediately before an intercepted paste", () => {
197 it("keeps the pre-paste React value behind the native paste revision", () => {
198 const snapshots = [
199 { eventCount: 0, value: "", selection: { start: 0, end: 0 } },
200 { eventCount: 1, value: "typed", selection: { start: 5, end: 5 } },
201 { eventCount: 2, value: "typed", selection: { start: 0, end: 5 } },
202 ];
203 // Both native platforms stamp the paste with its current value and selection.
204 // A render still carrying the typing caret cannot overwrite that selection.
205 expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 2, snapshots)).toBe(
206 1,
207 );
208 expect(isComposerNativeEcho("typed", { start: 0, end: 5 }, 2, snapshots)).toBe(true);
209 // The replacement is a parent edit at the acknowledged paste revision.
210 expect(resolveComposerControlledEventCount("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(
211 2,
212 );
213 expect(isComposerNativeEcho("pasted", { start: 6, end: 6 }, 2, snapshots)).toBe(false);
214 expect(acknowledgeComposerNativeEvent(2, 1)).toBeNull();
215 });
216});