| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | |
| 3 | import { |
| 4 | acknowledgeComposerNativeEvent, |
| 5 | assumeComposerControlledState, |
| 6 | isComposerNativeEcho, |
| 7 | pruneAcknowledgedComposerNativeEvents, |
| 8 | resolveComposerControlledEventCount, |
| 9 | } from "./composerEditorRevision"; |
| 10 | |
| 11 | describe("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 | |
| 30 | describe("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 | |
| 61 | describe("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 | |
| 104 | describe("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 | |
| 146 | describe("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 | |
| 196 | describe("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 | }); |