From 668477db38aa35ddb43a41e60b7c612c94184dc5 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sat, 18 Jul 2026 11:03:18 +0200 Subject: [PATCH] Add a fine-grained text splice op Text edits previously recorded ['set', path, whole_value] twice per keystroke (ops + inverse_ops), so history stored a full copy of the paragraph per keystroke and the op stream carried no meaning beyond "content changed". Transaction.splice(path, start, delete_count, text) records ['splice', [node_id, prop], start, delete_count, text] instead. Mark/annotation range adjustment happens inside op application (splice_annotated_text in doc_utils.ts, the single source of truth used by forward and inverse ops alike), and range nodes whose range collapsed away are garbage-collected ref-count-aware, like set() does. Pure insertions (every keystroke) take a fast path: insertion adjustment can never remove or truncate ranges, so the inverse is a plain splice by construction and no value copies or round-trip checks run on the hot path. Deletions verify losslessness with a round-trip check; lossy deletions carry the prior ranges verbatim as a 6th op element, so undo is always exact. insert_text and delete_selection (text branch) now emit splices; their public behavior is unchanged, and plain typing returns early before the mark/annotation restoration copies. History batching coalesces consecutive contiguous typing splices into one op using the inverses' stored lengths (no re-segmentation of the accumulated batch text), so a one-second typing batch stores a single small op instead of one string copy per keystroke. Character offsets are grapheme clusters (Intl.Segmenter), the same unit as all existing svedit text offsets. Co-Authored-By: Claude Fable 5 --- src/lib/Session.svelte.ts | 80 +++++++++++- src/lib/Transaction.svelte.ts | 193 +++++++++++++++++++++++------ src/lib/doc_utils.ts | 85 +++++++++++++ src/test/op_engine_helpers.ts | 46 ++++++- src/test/splice_ops.svelte.test.ts | 162 ++++++++++++++++++++++++ 5 files changed, 522 insertions(+), 44 deletions(-) create mode 100644 src/test/splice_ops.svelte.test.ts diff --git a/src/lib/Session.svelte.ts b/src/lib/Session.svelte.ts index 82307845..1f61ebae 100644 --- a/src/lib/Session.svelte.ts +++ b/src/lib/Session.svelte.ts @@ -58,6 +58,80 @@ export type ChangeEvent = { origin: 'transaction' | 'undo' | 'redo'; }; +/** + * Checks whether two ops are consecutive lossless text-insertion splices + * into the same property, i.e. the next insertion starts exactly where the + * previous one ended. Lossy inverses (a 6th element carries prior ranges) + * are never coalesced, so exact undo is preserved. + */ +function can_coalesce_insertion_splices( + prev_op: DocumentOperation | undefined, + prev_inverse: DocumentOperation | undefined, + next_op: DocumentOperation, + next_inverse: DocumentOperation +): boolean { + return ( + Array.isArray(prev_op) && + Array.isArray(next_op) && + prev_op[0] === 'splice' && + next_op[0] === 'splice' && + // Pure insertions only + prev_op[3] === 0 && + next_op[3] === 0 && + // Lossless inverses only + prev_inverse?.[0] === 'splice' && + prev_inverse.length === 5 && + next_inverse[0] === 'splice' && + next_inverse.length === 5 && + // Same text property + prev_op[1][0] === next_op[1][0] && + prev_op[1][1] === next_op[1][1] && + // Contiguous: the next insertion starts where the previous one ended + // (a lossless insertion inverse's delete_count is the inserted text's + // character length). + next_op[2] === prev_op[2] + prev_inverse[3] + ); +} + +/** + * Appends a transaction's ops to a history entry, coalescing the most + * common batching pattern — consecutive text-insertion splices — into a + * single splice op. A typing batch then stores one small op instead of one + * per keystroke. + */ +function append_ops_coalescing( + entry: HistoryEntry, + ops: DocumentOperation[], + inverse_ops: DocumentOperation[] +): void { + const last_index = entry.ops.length - 1; + if ( + ops.length === 1 && + can_coalesce_insertion_splices( + entry.ops[last_index], + entry.inverse_ops[last_index], + ops[0], + inverse_ops[0] + ) + ) { + const prev_op = entry.ops[last_index]; + const prev_inverse = entry.inverse_ops[last_index]; + entry.ops[last_index] = ['splice', prev_op[1], prev_op[2], 0, prev_op[4] + ops[0][4]]; + entry.inverse_ops[last_index] = [ + 'splice', + prev_op[1], + prev_op[2], + // Both inverses are lossless insertion splices (checked above), + // so their delete_counts are the two character lengths. + prev_inverse[3] + inverse_ops[0][3], + '' + ]; + } else { + entry.ops.push(...ops); + entry.inverse_ops.push(...inverse_ops); + } +} + export default class Session { #selection: Selection | null = $state.raw(null); @@ -353,10 +427,10 @@ export default class Session { now - this.last_batch_started < BATCH_WINDOW_MS; if (should_batch) { - // Append to existing history entry (within the batch window) + // Append to existing history entry (within the batch window), + // coalescing consecutive typing splices into one op. const last_entry = this.history[this.history_index]; - last_entry.ops.push(...transaction.ops); - last_entry.inverse_ops.push(...transaction.inverse_ops); + append_ops_coalescing(last_entry, transaction.ops, transaction.inverse_ops); last_entry.selection_after = this.selection; // Trigger update this.history = [...this.history]; diff --git a/src/lib/Transaction.svelte.ts b/src/lib/Transaction.svelte.ts index 1fa300ef..ebed64a3 100644 --- a/src/lib/Transaction.svelte.ts +++ b/src/lib/Transaction.svelte.ts @@ -17,6 +17,7 @@ import { inspect as doc_inspect, create_document_draft, apply_op_to_draft, + splice_annotated_text, build_reference_counts, visit_node_references, validate_node, @@ -40,6 +41,7 @@ import type { Attachment, Mark, Annotation, + Text, DocumentOperation, DynamicRecord, Inspection, @@ -59,6 +61,23 @@ function is_range_within_bounds(range: Attachment, length: number): boolean { ); } +/** + * Compares two range arrays (marks or annotations) for exact equality. + */ +function ranges_equal(a: Array, b: Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if ( + a[i].start_offset !== b[i].start_offset || + a[i].end_offset !== b[i].end_offset || + a[i].node_id !== b[i].node_id + ) { + return false; + } + } + return true; +} + /** * Transaction class for managing atomic document operations with undo/redo support. * @@ -312,6 +331,124 @@ export default class Transaction { return this; } + /** + * Splices a text property: deletes delete_count characters at start, + * then inserts text at start. Mark and annotation ranges are adjusted + * deterministically as part of applying the op (see splice_annotated_text + * in doc_utils.ts), and mark/annotation nodes whose range collapsed away + * are garbage-collected ref-count-aware, like set() does. + * + * Offsets are in characters (grapheme clusters, as segmented by + * Intl.Segmenter — the same unit as all svedit text offsets, see + * get_char_length in utils.ts). + * + * The recorded op is small — [start, delete_count, text] instead of a + * copy of the whole property value — which keeps long editing sessions + * flat in memory and makes the op stream meaningful (diffs, previews, + * persistence). The inverse is a plain splice when re-splicing the + * deleted text restores the prior ranges exactly; deletions that removed + * or truncated ranges are lossy, so their inverse carries the prior + * ranges verbatim. + * + * @param path - Path to a text property + * @param start - Character offset to splice at + * @param delete_count - Number of characters to delete + * @param text - Text to insert at start ('' for pure deletion) + * @returns This transaction instance for method chaining + * @throws {Error} If the path is not a text property or the range is out of bounds + * + * @example + * ```js + * tr.splice(['paragraph_1', 'content'], 5, 0, 'Hello'); // insert + * tr.splice(['paragraph_1', 'content'], 5, 5, ''); // delete + * tr.splice(['paragraph_1', 'content'], 5, 5, 'Hello'); // replace + * ``` + */ + splice(path: DocumentPath, start: number, delete_count: number, text: string): this { + const path_info = this.inspect(path); + if (path_info?.kind !== 'property' || path_info.type !== 'text') { + throw new Error( + `Transaction.splice requires a path that points to a text property, got ${JSON.stringify(path)}` + ); + } + + const node = this.get(path.slice(0, -1)); + const property_key_str = String(path.at(-1)); + const normalized_path = [node.id, property_key_str]; + + const content_length = get_char_length(node[property_key_str].content); + if ( + !Number.isInteger(start) || + !Number.isInteger(delete_count) || + start < 0 || + delete_count < 0 || + start + delete_count > content_length + ) { + throw new Error( + `Splice range [${start}, ${start + delete_count}) is out of bounds for content of length ${content_length}` + ); + } + + const op: DocumentOperation = ['splice', normalized_path, start, delete_count, text]; + + // Pure insertions (every keystroke) take a fast path: insertion + // adjustment never removes or truncates ranges, so the inverse is a + // plain splice by construction and no value copies or round-trip + // checks are needed. + if (delete_count === 0) { + this.ops.push(op); + this.inverse_ops.push(['splice', normalized_path, start, get_char_length(text), '']); + this._apply_op(op); + this._track_node_id(this.modified_node_ids, node.id); + return this; + } + + const previous_value: Text = structuredClone($state.snapshot(node[property_key_str])); + const deleted_text = char_slice(previous_value.content, start, start + delete_count); + const insert_length = get_char_length(text); + + const { value: next_value, removed_node_ids } = splice_annotated_text( + previous_value, + start, + delete_count, + text + ); + + // The inverse can be a plain splice only when re-splicing the deleted + // text back restores the prior ranges exactly (round-trip check). + const { value: round_trip } = splice_annotated_text( + next_value, + start, + insert_length, + deleted_text + ); + const lossless = + ranges_equal(round_trip.marks, previous_value.marks) && + ranges_equal(round_trip.annotations, previous_value.annotations); + + const inverse_op: DocumentOperation = lossless + ? ['splice', normalized_path, start, insert_length, deleted_text] + : [ + 'splice', + normalized_path, + start, + insert_length, + deleted_text, + { marks: previous_value.marks, annotations: previous_value.annotations } + ]; + + this.ops.push(op); + this.inverse_ops.push(inverse_op); + this._apply_op(op); + this._track_node_id(this.modified_node_ids, node.id); + + if (removed_node_ids.length > 0) { + this._cascade_delete_unreferenced_nodes(removed_node_ids); + } + + return this; + } + // Takes a subgraph and constructs new nodes from it // NOTE: all ids will be mapped to new unique ids. // NOTE: Omitted properties will be populated with default values. @@ -713,26 +850,9 @@ export default class Transaction { }; } else if (this.selection.type === 'text') { const path = this.selection.path; - const text = structuredClone($state.snapshot(this.get(path))); - - // Update the text content using character-based operations - const original_text = text.content; - text.content = - char_slice(original_text, 0, start) + - char_slice(original_text, end, get_char_length(original_text)); - - const marks_result = adjust_ranges_for_deletion(text.marks, start, end); - const annotations_result = adjust_ranges_for_deletion(text.annotations, start, end); - text.marks = marks_result.ranges; - text.annotations = annotations_result.ranges; - - this.set(path, text); - - // Delete removed mark/annotation nodes only if no other references remain. - this._cascade_delete_unreferenced_nodes([ - ...marks_result.removed_node_ids, - ...annotations_result.removed_node_ids - ]); + // Content change, range adjustment and cleanup of removed + // mark/annotation nodes all happen in splice(). + this.splice(path, start, end - start, ''); // Update the selection to the new caret position this.selection = { @@ -870,37 +990,32 @@ export default class Transaction { this.delete_selection(); } - const text_value = structuredClone($state.snapshot(this.get(this.selection.path))); const range = get_selection_range(this.selection)!; - - // Transform the plain text string using character-based operations - const current_text = text_value.content; - text_value.content = - char_slice(current_text, 0, range.start_offset) + - replaced_text + - char_slice(current_text, range.end_offset); - - // Calculate the change in length const delta = get_char_length(replaced_text); - text_value.marks = adjust_ranges_for_insertion(text_value.marks, range.start_offset, delta); - text_value.annotations = adjust_ranges_for_insertion( - text_value.annotations, - range.start_offset, - delta - ); - this.set(this.selection.path, text_value); // this will update the current state and create a history entry + // One splice op carries the content change and the deterministic + // mark/annotation range adjustment (see splice()). + this.splice(this.selection.path, range.start_offset, 0, replaced_text); // Setting the selection automatically triggers a re-render of the corresponding DOMSelection. const new_selection: Selection = { type: 'text', path: this.selection.path, - anchor_offset: range.start_offset + get_char_length(replaced_text), - focus_offset: range.start_offset + get_char_length(replaced_text) + anchor_offset: range.start_offset + delta, + focus_offset: range.start_offset + delta }; this.selection = new_selection; + // Plain typing has no marks/annotations to restore — skip the value + // copies below on the per-keystroke path. + if (marks.length === 0 && annotations.length === 0) { + return this; + } + const text_property_definition = this.inspect(this.selection.path); + // Restored marks/annotations below are applied on top of the + // post-splice value as a separate set op. + const text_value: Text = structuredClone($state.snapshot(this.get(this.selection.path))); const next_text = structuredClone(text_value); let text_changed = false; diff --git a/src/lib/doc_utils.ts b/src/lib/doc_utils.ts index f3dfa5ea..60105af1 100644 --- a/src/lib/doc_utils.ts +++ b/src/lib/doc_utils.ts @@ -10,6 +10,9 @@ import { is_path_string_segment_valid, get_selection_range, get_char_length, + char_slice, + adjust_ranges_for_deletion, + adjust_ranges_for_insertion, serialize_path, are_ranges_exclusive } from './utils.js'; @@ -749,6 +752,71 @@ export function inspect(schema: DocumentSchema, doc: Document, path: DocumentPat } } +/** + * Splices an annotated text value: deletes [start, start + delete_count), + * inserts text at start, and adjusts mark/annotation ranges accordingly. + * + * This is the single source of truth for how a text splice affects ranges; + * both forward ops and their inverses go through it (see the 'splice' case + * in apply_op_to_draft). Offsets are in characters (grapheme clusters, as + * segmented by Intl.Segmenter — the same unit as all svedit text offsets, + * see get_char_length in utils.js). + * + * When prior_ranges is given (lossy inverse ops carry the exact ranges to + * restore), range adjustment is skipped and the given ranges are used + * verbatim. + * + * The input value is never mutated. + * + * @param value - The current {content, marks, annotations} value + * @param start - Character offset to splice at + * @param delete_count - Number of characters to delete + * @param text - Text to insert at start ('' for pure deletion) + * @param prior_ranges - Exact ranges to restore instead of adjusting + * @returns The next value, plus the ids of mark/annotation nodes whose range collapsed away + */ +export function splice_annotated_text( + value: Text, + start: number, + delete_count: number, + text: string, + prior_ranges?: { marks: Array; annotations: Array } +): { value: Text; removed_node_ids: NodeId[] } { + const content = + char_slice(value.content, 0, start) + text + char_slice(value.content, start + delete_count); + + if (prior_ranges) { + return { + value: { + content, + marks: structuredClone(prior_ranges.marks), + annotations: structuredClone(prior_ranges.annotations) + }, + removed_node_ids: [] + }; + } + + let marks = value.marks; + let annotations = value.annotations; + const removed_node_ids: NodeId[] = []; + + if (delete_count > 0) { + const marks_result = adjust_ranges_for_deletion(marks, start, start + delete_count); + const annotations_result = adjust_ranges_for_deletion(annotations, start, start + delete_count); + marks = marks_result.ranges; + annotations = annotations_result.ranges; + removed_node_ids.push(...marks_result.removed_node_ids, ...annotations_result.removed_node_ids); + } + + const insert_length = get_char_length(text); + if (insert_length > 0) { + marks = adjust_ranges_for_insertion(marks, start, insert_length); + annotations = adjust_ranges_for_insertion(annotations, start, insert_length); + } + + return { value: { content, marks, annotations }, removed_node_ids }; +} + /** * Creates a mutable draft of a document: a new document object with a new * nodes map, sharing the (immutable) node objects with the original. @@ -787,6 +855,23 @@ export function apply_op_to_draft(draft: Document, op: DocumentOperation): Docum ...draft.nodes[node_id], [property]: structuredClone(args[1]) }; + } else if (type === 'splice') { + // args: [[node_id, property], start, delete_count, text, prior_ranges?] + const [node_id, property] = args[0]; + const node = draft.nodes[node_id]; + // Guard with a clear error: op streams may come from outside a + // Transaction (replay, ingest), where nothing has validated the + // target yet. + if (typeof node?.[property]?.content !== 'string') { + throw new Error( + `Cannot splice ${JSON.stringify([node_id, property])}: not an annotated text value` + ); + } + const { value } = splice_annotated_text(node[property], args[1], args[2], args[3], args[4]); + draft.nodes[node_id] = { + ...node, + [property]: value + }; } else if (type === 'create') { draft.nodes[args[0].id] = structuredClone(args[0]); } else if (type === 'delete') { diff --git a/src/test/op_engine_helpers.ts b/src/test/op_engine_helpers.ts index 91f92f6f..96c3a246 100644 --- a/src/test/op_engine_helpers.ts +++ b/src/test/op_engine_helpers.ts @@ -4,11 +4,13 @@ * create_test_session.js. */ -import type create_test_session from './create_test_session.js'; -import type { Text } from '../lib/types.js'; +import create_test_session from './create_test_session.js'; +import type { DocumentPath, Text } from '../lib/types.js'; type TestSession = ReturnType; +export const title_path: DocumentPath = ['story_1', 'title']; + export function text_value(content: string): Text { return { content, marks: [], annotations: [] }; } @@ -22,3 +24,43 @@ export function set_button_content( tr.set(['button_1', 'content'], text_value(text)); session.apply(tr, { batch }); } + +export function set_caret(session: TestSession, offset: number): void { + session.selection = { + type: 'text', + path: title_path, + anchor_offset: offset, + focus_offset: offset + }; +} + +// Emulates a keystroke: the transaction picks up the current session +// selection, which apply() advances after each insert. +export function press_key(session: TestSession, char: string): void { + const tr = session.tr; + tr.insert_text(char); + session.apply(tr, { batch: true }); +} + +export function current_entry(session: TestSession) { + return session.history[session.history_index]; +} + +// The test session shares module-level schema objects; clone so per-test +// type additions don't leak into other tests. +export function create_annotated_session(): TestSession { + const session = create_test_session(); + session.schema = structuredClone(session.schema); + session.schema.strong = { kind: 'mark', properties: {} }; + session.schema.comment = { kind: 'annotation', properties: {} }; + (session.schema.story.properties.title as any).mark_types = ['strong']; + (session.schema.story.properties.title as any).annotation_types = ['comment']; + return session; +} + +export function annotate(session: TestSession, anchor_offset: number, focus_offset: number) { + session.selection = { type: 'text', path: title_path, anchor_offset, focus_offset }; + session.apply(session.tr.toggle_annotation('comment', {})); + const { annotations } = session.get(title_path); + return annotations[annotations.length - 1]; +} diff --git a/src/test/splice_ops.svelte.test.ts b/src/test/splice_ops.svelte.test.ts new file mode 100644 index 00000000..8b1b9432 --- /dev/null +++ b/src/test/splice_ops.svelte.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import create_test_session from './create_test_session.js'; +import { apply_op } from '../lib/doc_utils.js'; +import type { Text } from '../lib/types.js'; +import { + title_path, + set_caret, + press_key, + current_entry, + create_annotated_session, + annotate +} from './op_engine_helpers.js'; + +describe('splice op', () => { + it('records a single small splice op for text insertion', () => { + const session = create_test_session(); + set_caret(session, 5); + + const tr = session.tr; + tr.insert_text('XY'); + session.apply(tr); + + expect(session.get(title_path).content).toBe('FirstXY story'); + expect(current_entry(session).ops).toEqual([['splice', ['story_1', 'title'], 5, 0, 'XY']]); + expect(current_entry(session).inverse_ops).toEqual([ + ['splice', ['story_1', 'title'], 5, 2, ''] + ]); + }); + + it('replaces a range in one splice', () => { + const session = create_test_session(); + session.apply(session.tr.splice(title_path, 0, 5, 'Last')); + + expect(session.get(title_path).content).toBe('Last story'); + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + }); + + it('validates splice bounds', () => { + const session = create_test_session(); + expect(() => session.tr.splice(title_path, 0, 99, '')).toThrow('out of bounds'); + expect(() => session.tr.splice(['story_1', 'layout'], 0, 0, 'x')).toThrow('text property'); + }); + + it('rejects splice ops on non-text values at the op application layer', () => { + // Op streams may bypass Transaction (replay, ingest); the application + // layer fails with a clear error instead of a deep TypeError. + const session = create_test_session(); + expect(() => apply_op(session.doc, ['splice', ['story_1', 'layout'], 0, 0, 'x'])).toThrow( + 'annotated text' + ); + }); + + it('expands an annotation when typing inside it, with a lossless inverse', () => { + const session = create_annotated_session(); + annotate(session, 0, 5); + + set_caret(session, 2); + const tr = session.tr; + tr.insert_text('ab'); + session.apply(tr); + + expect(session.get(title_path).content).toBe('Fiabrst story'); + expect(session.get(title_path).annotations[0].start_offset).toBe(0); + expect(session.get(title_path).annotations[0].end_offset).toBe(7); + // Lossless inverse: a plain splice without a prior-ranges payload + expect(current_entry(session).inverse_ops[0].length).toBe(5); + + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + expect(session.get(title_path).annotations[0].start_offset).toBe(0); + expect(session.get(title_path).annotations[0].end_offset).toBe(5); + }); + + it('carries prior ranges on the inverse when a deletion truncates an annotation', () => { + const session = create_annotated_session(); + annotate(session, 3, 8); + + session.selection = { type: 'text', path: title_path, anchor_offset: 1, focus_offset: 5 }; + session.apply(session.tr.delete_selection()); + + expect(session.get(title_path).content).toBe('F story'); + expect(session.get(title_path).annotations[0].start_offset).toBe(1); + expect(session.get(title_path).annotations[0].end_offset).toBe(4); + // Lossy inverse: the 6th element restores the prior ranges verbatim + expect(current_entry(session).inverse_ops[0].length).toBe(6); + + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + expect(session.get(title_path).annotations[0].start_offset).toBe(3); + expect(session.get(title_path).annotations[0].end_offset).toBe(8); + }); + + it('cascade-deletes a fully deleted annotation node and restores it on undo', () => { + const session = create_annotated_session(); + const range = annotate(session, 6, 11); + const annotation_node_id = range.node_id; + + session.selection = { type: 'text', path: title_path, anchor_offset: 5, focus_offset: 11 }; + session.apply(session.tr.delete_selection()); + + expect(session.get(title_path).content).toBe('First'); + expect(session.get(title_path).annotations.length).toBe(0); + expect(session.get(annotation_node_id)).toBeUndefined(); + expect(current_entry(session).ops).toContainEqual(['delete', annotation_node_id]); + + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + expect(session.get(annotation_node_id)).toBeTruthy(); + expect(session.get(title_path).annotations[0].start_offset).toBe(6); + expect(session.get(title_path).annotations[0].end_offset).toBe(11); + }); + + it('handles character offsets, not UTF-16 units', () => { + const session = create_test_session(); + session.apply(session.tr.set(title_path, { content: '😀ab', marks: [], annotations: [] })); + + set_caret(session, 1); + const tr = session.tr; + tr.insert_text('X'); + session.apply(tr); + + expect(session.get(title_path).content).toBe('😀Xab'); + }); +}); + +describe('splice coalescing in history batching', () => { + it('coalesces consecutive typing into one op, undone in one step', () => { + const session = create_test_session(); + set_caret(session, 11); + + press_key(session, 'a'); + press_key(session, 'b'); + press_key(session, 'c'); + + expect(session.get(title_path).content).toBe('First storyabc'); + expect(session.history.length).toBe(1); + expect(current_entry(session).ops).toEqual([['splice', ['story_1', 'title'], 11, 0, 'abc']]); + expect(current_entry(session).inverse_ops).toEqual([ + ['splice', ['story_1', 'title'], 11, 3, ''] + ]); + + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + }); + + it('does not coalesce non-contiguous insertions', () => { + const session = create_test_session(); + set_caret(session, 11); + press_key(session, 'a'); + + set_caret(session, 0); + press_key(session, 'x'); + + expect(session.get(title_path).content).toBe('xFirst storya'); + expect(session.history.length).toBe(1); + expect(current_entry(session).ops.length).toBe(2); + + session.undo(); + expect(session.get(title_path).content).toBe('First story'); + }); +});