|
@@ -6,6 +6,10 @@ import {
|
|
|
useSlashGroups,
|
|
useSlashGroups,
|
|
|
} from "@/composables/useSuggestionGroups"
|
|
} from "@/composables/useSuggestionGroups"
|
|
|
import type { FormattingHandlers } from "@/lib/formatting"
|
|
import type { FormattingHandlers } from "@/lib/formatting"
|
|
|
|
|
+import {
|
|
|
|
|
+ createOperationHistory,
|
|
|
|
|
+ type SetBlockContentOp,
|
|
|
|
|
+} from "@/lib/operation-history"
|
|
|
|
|
|
|
|
function createMockSession(
|
|
function createMockSession(
|
|
|
kind: string = "command",
|
|
kind: string = "command",
|
|
@@ -105,3 +109,141 @@ describe("Editor integration — menu routing", () => {
|
|
|
expect(activeSession.value!.range).toEqual({ from: 0, to: 9 })
|
|
expect(activeSession.value!.range).toEqual({ from: 0, to: 9 })
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
+
|
|
|
|
|
+describe("content-change-op coalescing", () => {
|
|
|
|
|
+ it("coalesces rapid changes for the same block", () => {
|
|
|
|
|
+ const history = createOperationHistory()
|
|
|
|
|
+ const COALESCE_MS = 500
|
|
|
|
|
+ let canUndo = false
|
|
|
|
|
+ let canRedo = false
|
|
|
|
|
+
|
|
|
|
|
+ function handleOp(op: {
|
|
|
|
|
+ blockId: string
|
|
|
|
|
+ previousContent: string
|
|
|
|
|
+ newContent: string
|
|
|
|
|
+ timestamp: number
|
|
|
|
|
+ }) {
|
|
|
|
|
+ const last = history.peek()
|
|
|
|
|
+ if (
|
|
|
|
|
+ last?.type === "set-block-content" &&
|
|
|
|
|
+ last.blockId === op.blockId &&
|
|
|
|
|
+ op.timestamp - last.timestamp < COALESCE_MS
|
|
|
|
|
+ ) {
|
|
|
|
|
+ ;(last as SetBlockContentOp).newContent = op.newContent
|
|
|
|
|
+ last.timestamp = op.timestamp
|
|
|
|
|
+ } else {
|
|
|
|
|
+ void history.execute({
|
|
|
|
|
+ type: "set-block-content",
|
|
|
|
|
+ blockId: op.blockId,
|
|
|
|
|
+ previousContent: op.previousContent,
|
|
|
|
|
+ newContent: op.newContent,
|
|
|
|
|
+ timestamp: op.timestamp,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ canUndo = history.canUndo
|
|
|
|
|
+ canRedo = history.canRedo
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Rapid typing — should coalesce into 1 entry
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "", newContent: "h", timestamp: 1000 })
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "h", newContent: "he", timestamp: 1050 })
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "he", newContent: "hel", timestamp: 1120 })
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "hel", newContent: "hell", timestamp: 1180 })
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "hell", newContent: "hello", timestamp: 1250 })
|
|
|
|
|
+
|
|
|
|
|
+ expect(canUndo).toBe(true)
|
|
|
|
|
+ expect(history.peek()?.type).toBe("set-block-content")
|
|
|
|
|
+ expect((history.peek() as SetBlockContentOp).newContent).toBe("hello")
|
|
|
|
|
+ expect(history.peek() as SetBlockContentOp).not.toBeNull()
|
|
|
|
|
+
|
|
|
|
|
+ // Undo should revert directly from "hello" to ""
|
|
|
|
|
+ const inverse = history.undo()
|
|
|
|
|
+ expect(inverse).not.toBeNull()
|
|
|
|
|
+ expect((inverse as SetBlockContentOp).newContent).toBe("")
|
|
|
|
|
+ canUndo = history.canUndo
|
|
|
|
|
+ expect(canUndo).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("creates separate entries after a pause", () => {
|
|
|
|
|
+ const history = createOperationHistory()
|
|
|
|
|
+ const COALESCE_MS = 500
|
|
|
|
|
+
|
|
|
|
|
+ function handleOp(op: {
|
|
|
|
|
+ blockId: string
|
|
|
|
|
+ previousContent: string
|
|
|
|
|
+ newContent: string
|
|
|
|
|
+ timestamp: number
|
|
|
|
|
+ }) {
|
|
|
|
|
+ const last = history.peek()
|
|
|
|
|
+ if (
|
|
|
|
|
+ last?.type === "set-block-content" &&
|
|
|
|
|
+ last.blockId === op.blockId &&
|
|
|
|
|
+ op.timestamp - last.timestamp < COALESCE_MS
|
|
|
|
|
+ ) {
|
|
|
|
|
+ ;(last as SetBlockContentOp).newContent = op.newContent
|
|
|
|
|
+ last.timestamp = op.timestamp
|
|
|
|
|
+ } else {
|
|
|
|
|
+ void history.execute({
|
|
|
|
|
+ type: "set-block-content",
|
|
|
|
|
+ blockId: op.blockId,
|
|
|
|
|
+ previousContent: op.previousContent,
|
|
|
|
|
+ newContent: op.newContent,
|
|
|
|
|
+ timestamp: op.timestamp,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // First burst
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "", newContent: "hi", timestamp: 1000 })
|
|
|
|
|
+ // Pause > 500ms
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "hi", newContent: "hi there", timestamp: 1600 })
|
|
|
|
|
+
|
|
|
|
|
+ // Two separate entries
|
|
|
|
|
+ const first = history.undo()
|
|
|
|
|
+ expect((first as SetBlockContentOp).newContent).toBe("hi")
|
|
|
|
|
+ expect(history.canUndo).toBe(true)
|
|
|
|
|
+ const second = history.undo()
|
|
|
|
|
+ expect((second as SetBlockContentOp).newContent).toBe("")
|
|
|
|
|
+ expect(history.canUndo).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("creates separate entries for different blocks", () => {
|
|
|
|
|
+ const history = createOperationHistory()
|
|
|
|
|
+ const COALESCE_MS = 500
|
|
|
|
|
+
|
|
|
|
|
+ function handleOp(op: {
|
|
|
|
|
+ blockId: string
|
|
|
|
|
+ previousContent: string
|
|
|
|
|
+ newContent: string
|
|
|
|
|
+ timestamp: number
|
|
|
|
|
+ }) {
|
|
|
|
|
+ const last = history.peek()
|
|
|
|
|
+ if (
|
|
|
|
|
+ last?.type === "set-block-content" &&
|
|
|
|
|
+ last.blockId === op.blockId &&
|
|
|
|
|
+ op.timestamp - last.timestamp < COALESCE_MS
|
|
|
|
|
+ ) {
|
|
|
|
|
+ ;(last as SetBlockContentOp).newContent = op.newContent
|
|
|
|
|
+ last.timestamp = op.timestamp
|
|
|
|
|
+ } else {
|
|
|
|
|
+ void history.execute({
|
|
|
|
|
+ type: "set-block-content",
|
|
|
|
|
+ blockId: op.blockId,
|
|
|
|
|
+ previousContent: op.previousContent,
|
|
|
|
|
+ newContent: op.newContent,
|
|
|
|
|
+ timestamp: op.timestamp,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ handleOp({ blockId: "a", previousContent: "", newContent: "hello", timestamp: 1000 })
|
|
|
|
|
+ handleOp({ blockId: "b", previousContent: "", newContent: "world", timestamp: 1100 })
|
|
|
|
|
+
|
|
|
|
|
+ // Two separate entries (different block)
|
|
|
|
|
+ const first = history.undo()
|
|
|
|
|
+ expect((first as SetBlockContentOp).blockId).toBe("b")
|
|
|
|
|
+ expect((first as SetBlockContentOp).newContent).toBe("") // reverted to ""
|
|
|
|
|
+ expect(history.canUndo).toBe(true)
|
|
|
|
|
+ expect(history.undo()).not.toBeNull()
|
|
|
|
|
+ })
|
|
|
|
|
+})
|