diff --git a/README.md b/README.md index 5bbe64a..c361e8a 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,7 @@ The collaborative data structures you attach to a doc are: - [SynkList](#synklist) - [SynkText](#synktext) - [SynkInt](#synkint) -- [SynkString](#synkstring) -- [SynkBool](#synkbool) -- [SynkDouble](#synkdouble) +- [SynkValue](#synkvaluet) ### Creating a document @@ -121,45 +119,31 @@ counter.value; // 8 > Full example - [`example/synk_int_example.dart`](example/synk_int_example.dart) -### `SynkString` +### `SynkValue` -A single-value string register. Concurrent writes are resolved deterministically via LWW — the write with the higher logical clock wins. If clocks tie, the higher `clientId` wins. +A generic single-value collaborative register. Concurrent writes are resolved deterministically via LWW — the write with the higher logical clock wins. If clocks tie, the higher `clientId` wins. -```dart -final title = SynkString(doc, 'title'); +`SynkValue` supports generic JSON-serializable primitives like `String`, `num`, and `bool`. If the value has not been set yet, it returns `null`. +```dart +// A collaborative string +final title = SynkValue(doc, 'title'); +title.value; // null title.set('Hello, World!'); title.value; // 'Hello, World!' -``` - -> Full example - [`example/synk_string_example.dart`](example/synk_string_example.dart) - -### `SynkBool` - -Same LWW semantics as `SynkString`, with an extra `toggle()` helper. - -```dart -final flag = SynkBool(doc, 'isPublished'); +// A collaborative boolean +final flag = SynkValue(doc, 'isPublished'); flag.set(true); -flag.toggle(); -flag.value; // false -``` - -> Full example - [`example/synk_bool_example.dart`](example/synk_bool_example.dart) - -### `SynkDouble` - -Same LWW semantics as `SynkString`. JSON-safe: integer payloads from the wire are cast to `double` transparently. - -```dart -final price = SynkDouble(doc, 'price'); +flag.value; // true +// A collaborative double +final price = SynkValue(doc, 'price'); price.set(9.99); price.value; // 9.99 ``` -> Full example - [`example/synk_double_example.dart`](example/synk_double_example.dart) +> Full example - [`example/synk_value_example.dart`](example/synk_value_example.dart) ## Syncing Between Peers diff --git a/example/synk_bool_example.dart b/example/synk_bool_example.dart deleted file mode 100644 index 27ba764..0000000 --- a/example/synk_bool_example.dart +++ /dev/null @@ -1,34 +0,0 @@ -// ignore_for_file: cascade_invocations, avoid_print - -import 'package:synk/synk.dart'; - -void main() { - // ── Local usage ── - final doc = SynkDoc(); - final flag = SynkBool(doc, 'isPublished'); - - flag.set(true); - print('Published: ${flag.value}'); // true - - flag.toggle(); - print('Published: ${flag.value}'); // false - - // ── Multi-peer: concurrent conflicting writes ── - final docAlice = SynkDoc(clientId: 100); - final docBob = SynkDoc(clientId: 200); - - final flagA = SynkBool(docAlice, 'darkMode'); - final flagB = SynkBool(docBob, 'darkMode'); - - // Both set the flag concurrently at the same clock (offline) - flagA.set(true); // client 100 - flagB.set(false); // client 200 - - // Sync bidirectionally - SynkProtocol.applyUpdate(docBob, SynkProtocol.encodeStateAsUpdate(docAlice)); - SynkProtocol.applyUpdate(docAlice, SynkProtocol.encodeStateAsUpdate(docBob)); - - // LWW: higher clientId wins the tie → client 200 (false) wins - print('Alice darkMode: ${flagA.value}'); // false - print('Bob darkMode: ${flagB.value}'); // false -} diff --git a/example/synk_double_example.dart b/example/synk_double_example.dart deleted file mode 100644 index fed68a3..0000000 --- a/example/synk_double_example.dart +++ /dev/null @@ -1,37 +0,0 @@ -// ignore_for_file: cascade_invocations, avoid_print - -import 'package:synk/synk.dart'; - -void main() { - // ── Local usage ── - final doc = SynkDoc(); - final price = SynkDouble(doc, 'price'); - - price.set(9.99); - print('Price: ${price.value}'); // 9.99 - - price.set(14.99); - print('Price: ${price.value}'); // 14.99 - - // ── Multi-peer: concurrent conflicting writes ── - final docAlice = SynkDoc(clientId: 1); - final docBob = SynkDoc(clientId: 2); - - final sliderA = SynkDouble(docAlice, 'volume'); - final sliderB = SynkDouble(docBob, 'volume'); - - // Alice makes two edits (clock advances to 1) - sliderA.set(0.5); - sliderA.set(0.8); - - // Bob makes one edit (clock stays at 0) - sliderB.set(0.2); - - // Sync bidirectionally - SynkProtocol.applyUpdate(docBob, SynkProtocol.encodeStateAsUpdate(docAlice)); - SynkProtocol.applyUpdate(docAlice, SynkProtocol.encodeStateAsUpdate(docBob)); - - // LWW: Alice's clock (1) > Bob's clock (0) → Alice wins - print('Alice volume: ${sliderA.value}'); // 0.8 - print('Bob volume: ${sliderB.value}'); // 0.8 -} diff --git a/example/synk_string_example.dart b/example/synk_string_example.dart deleted file mode 100644 index 0f34376..0000000 --- a/example/synk_string_example.dart +++ /dev/null @@ -1,37 +0,0 @@ -// ignore_for_file: cascade_invocations, avoid_print - -import 'package:synk/synk.dart'; - -void main() { - // ── Local usage ── - final doc = SynkDoc(); - final title = SynkString(doc, 'title'); - - title.set('Hello'); - print('Title: ${title.value}'); // Hello - - title.set('Hello, World!'); - print('Title: ${title.value}'); // Hello, World! - - // ── Multi-peer: concurrent conflicting writes ── - final docAlice = SynkDoc(clientId: 1); - final docBob = SynkDoc(clientId: 2); - - final titleA = SynkString(docAlice, 'docTitle'); - final titleB = SynkString(docBob, 'docTitle'); - - // Alice makes two edits (her clock advances to 1) - titleA.set('Draft'); - titleA.set('Final Draft'); - - // Bob makes one edit (his clock stays at 0) - titleB.set("Bob's Version"); - - // Sync bidirectionally - SynkProtocol.applyUpdate(docBob, SynkProtocol.encodeStateAsUpdate(docAlice)); - SynkProtocol.applyUpdate(docAlice, SynkProtocol.encodeStateAsUpdate(docBob)); - - // LWW: Alice's clock (1) > Bob's clock (0) → Alice wins - print('Alice: ${titleA.value}'); // Final Draft - print('Bob: ${titleB.value}'); // Final Draft -} diff --git a/example/synk_value_example.dart b/example/synk_value_example.dart new file mode 100644 index 0000000..6e9e2f3 --- /dev/null +++ b/example/synk_value_example.dart @@ -0,0 +1,53 @@ +// ignore_for_file: avoid_print + +import 'package:synk/synk.dart'; + +void main() { + final docA = SynkDoc(clientId: 1); + final docB = SynkDoc(clientId: 2); + + print('--- SynkValue Example ---'); + print('SynkValue behaves like a Last-Writer-Wins (LWW) Register'); + print('It stores a single, overwritable generic value.\n'); + + // Initialize two peers with the same variable name + final titleA = SynkValue(docA, 'pageTitle'); + final titleB = SynkValue(docB, 'pageTitle'); + + print('Initial titleA value: ${titleA.value}'); // null (unset) + print('Initial titleB value: ${titleB.value}\n'); + + // Peer A edits the document + titleA.set('Flutter Conference 2026'); + print('Alice set title to: ${titleA.value}'); + + // Peer B edits concurrently offline (a conflict!) + titleB.set('Dart Developer Summit'); + print('Bob set title to: ${titleB.value}\n'); + + // Sync to resolve the conflict completely + print('--- Synk resolves the tie automatically ---'); + final aliceSv = SynkProtocol.encodeStateVector(docA); + final bobUpdate = SynkProtocol.encodeStateAsUpdate(docB, aliceSv); + SynkProtocol.applyUpdate(docA, bobUpdate); + + final bobSv = SynkProtocol.encodeStateVector(docB); + final aliceUpdate = SynkProtocol.encodeStateAsUpdate(docA, bobSv); + SynkProtocol.applyUpdate(docB, aliceUpdate); + + print("Alice's final title: ${titleA.value}"); + print("Bob's final title: ${titleB.value}"); + // Notice that they both converge to 'Dart Developer Summit' because when + // clocks tie, the higher clientId wins deterministically! + + // They also work seamlessly for numbers and booleans + final flag = SynkValue(docA, 'isVisible'); + final opacity = SynkValue(docA, 'opacity'); + + flag.set(true); + opacity.set(0.7); + + print('\nOther types supported:'); + print('bool flag: ${flag.value}'); + print('double opacity: ${opacity.value}'); +} diff --git a/lib/src/types/synk_double.dart b/lib/src/types/synk_double.dart deleted file mode 100644 index 65fb8ba..0000000 --- a/lib/src/types/synk_double.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:synk/synk.dart'; - -/// {@template synk_double} -/// A collaborative single-value double (floating-point) register. -/// -/// [SynkDouble] implements a Last-Writer-Wins (LWW) Register. -/// {@endtemplate} -class SynkDouble { - /// {@macro synk_double} - SynkDouble(this.doc, this.name) { - doc.addListener(_processItem); - // Apply existing history - for (final clientItems in doc.store.values) { - clientItems.forEach(_processItem); - } - } - - /// The document this register belongs to. - final SynkDoc doc; - - /// The unique key name of this register in the document. - final String name; - - Item? _activeItem; - - void _processItem(Item item) { - if (item.parentKey == name) { - _applyRemoteItem(item); - } - } - - void _applyRemoteItem(Item item) { - if (item.content is! num) return; // double or int - - final existing = _activeItem; - if (existing != null) { - final a = item.id; - final b = existing.id; - if (a.clock > b.clock || (a.clock == b.clock && a.client > b.client)) { - existing.delete(); - _activeItem = item; - } else { - item.delete(); - } - } else { - _activeItem = item; - } - } - - /// Disposes the [SynkDouble] instance. - void dispose() { - doc.removeListener(_processItem); - } - - /// Sets the register to a new [value]. - void set(double value) { - doc.transact((txn) { - final item = Item( - id: txn.getNextId(), - parentKey: name, - content: value, - ); - doc.addItem(item); - }); - } - - /// Gets the current resolved value. Returns 0 if unset. - double get value { - final item = _activeItem; - if (item == null || item.deleted) return 0; - return (item.content as num).toDouble(); - } -} diff --git a/lib/src/types/synk_string.dart b/lib/src/types/synk_string.dart deleted file mode 100644 index 83ae019..0000000 --- a/lib/src/types/synk_string.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:synk/synk.dart'; - -/// {@template synk_string} -/// A collaborative single-value string register. -/// -/// [SynkString] implements a Last-Writer-Wins (LWW) Register. -/// When two users modify the string concurrently, the one with the higher -/// logical clock (or client ID tie-breaker) wins. -/// {@endtemplate} -class SynkString { - /// {@macro synk_string} - SynkString(this.doc, this.name) { - doc.addListener(_processItem); - // Apply existing history - for (final clientItems in doc.store.values) { - clientItems.forEach(_processItem); - } - } - - /// The document this register belongs to. - final SynkDoc doc; - - /// The unique key name of this register in the document. - final String name; - - Item? _activeItem; - - void _processItem(Item item) { - if (item.parentKey == name) { - _applyRemoteItem(item); - } - } - - void _applyRemoteItem(Item item) { - if (item.content is! String) return; - - final existing = _activeItem; - if (existing != null) { - final a = item.id; - final b = existing.id; - if (a.clock > b.clock || (a.clock == b.clock && a.client > b.client)) { - existing.delete(); - _activeItem = item; - } else { - item.delete(); - } - } else { - _activeItem = item; - } - } - - /// Disposes the [SynkString] instance. - void dispose() { - doc.removeListener(_processItem); - } - - /// Sets the register to a new [value]. - void set(String value) { - doc.transact((txn) { - final item = Item( - id: txn.getNextId(), - parentKey: name, - content: value, - ); - doc.addItem(item); - }); - } - - /// Gets the current resolved value. Returns an empty string if unset. - String get value { - final item = _activeItem; - if (item == null || item.deleted) return ''; - return item.content as String; - } -} diff --git a/lib/src/types/synk_bool.dart b/lib/src/types/synk_value.dart similarity index 58% rename from lib/src/types/synk_bool.dart rename to lib/src/types/synk_value.dart index 6d8af2f..40902fc 100644 --- a/lib/src/types/synk_bool.dart +++ b/lib/src/types/synk_value.dart @@ -1,15 +1,18 @@ -// ignore_for_file: avoid_positional_boolean_parameters - import 'package:synk/synk.dart'; -/// {@template synk_bool} -/// A collaborative boolean toggle register. +/// {@template synk_value} +/// A collaborative single-value register. +/// +/// [SynkValue] implements a Last-Writer-Wins (LWW) Register. +/// When two users modify the generic value `T` concurrently, the one with the +/// higher logical clock (or client ID tie-breaker) wins. /// -/// [SynkBool] implements a Last-Writer-Wins (LWW) Register. +/// Supported generic types include standard JSON-serializable types: +/// `String`, `num` (and `double`), and `bool`. /// {@endtemplate} -class SynkBool { - /// {@macro synk_bool} - SynkBool(this.doc, this.name) { +class SynkValue { + /// {@macro synk_value} + SynkValue(this.doc, this.name) { doc.addListener(_processItem); // Apply existing history for (final clientItems in doc.store.values) { @@ -32,8 +35,6 @@ class SynkBool { } void _applyRemoteItem(Item item) { - if (item.content is! bool) return; - final existing = _activeItem; if (existing != null) { final a = item.id; @@ -49,13 +50,13 @@ class SynkBool { } } - /// Disposes the [SynkBool] instance. + /// Disposes the [SynkValue] instance. void dispose() { doc.removeListener(_processItem); } /// Sets the register to a new [value]. - void set(bool value) { + void set(T value) { doc.transact((txn) { final item = Item( id: txn.getNextId(), @@ -66,15 +67,16 @@ class SynkBool { }); } - /// Toggles the current boolean value. - void toggle() { - set(!value); - } - - /// Gets the current resolved value. Returns false if unset. - bool get value { + /// Gets the current resolved value. + T? get value { final item = _activeItem; - if (item == null || item.deleted) return false; - return item.content as bool; + if (item == null || item.deleted) return null; + + // Type casting logic for numbers as JSON encodes them lossily + if (T == double && item.content is int) { + return (item.content as int).toDouble() as T; + } + + return item.content as T; } } diff --git a/lib/synk.dart b/lib/synk.dart index e31be57..a5b30bc 100644 --- a/lib/synk.dart +++ b/lib/synk.dart @@ -7,12 +7,10 @@ export 'src/document/transaction.dart'; export 'src/protocol/synk_protocol.dart'; export 'src/structs/id.dart'; export 'src/structs/item.dart'; -export 'src/types/synk_bool.dart'; -export 'src/types/synk_double.dart'; export 'src/types/synk_int.dart'; export 'src/types/synk_list.dart'; export 'src/types/synk_map.dart'; -export 'src/types/synk_string.dart'; export 'src/types/synk_text.dart'; +export 'src/types/synk_value.dart'; export 'src/utils/decoder.dart'; export 'src/utils/encoder.dart'; diff --git a/test/types/synk_bool_test.dart b/test/types/synk_bool_test.dart deleted file mode 100644 index d21f7ed..0000000 --- a/test/types/synk_bool_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:synk/synk.dart'; -import 'package:test/test.dart'; - -void main() { - group('SynkBool', () { - test('initializes to false', () { - final doc = SynkDoc(); - final flag = SynkBool(doc, 'isActive'); - expect(flag.value, isFalse); - }); - - test('sets and toggles correctly locally', () { - final doc = SynkDoc(); - final flag = SynkBool(doc, 'isActive'); - - flag.set(true); - expect(flag.value, isTrue); - - flag.toggle(); - expect(flag.value, isFalse); - }); - - test('LWW rule applies over the network deterministically', () { - final docA = SynkDoc(clientId: 100); - final docB = SynkDoc(clientId: 200); - - final flagA = SynkBool(docA, 'flag'); - final flagB = SynkBool(docB, 'flag'); - - // Both set it concurrently (clock = 0 for both) - flagA.set(true); - flagB.set(false); - - final updateA = SynkProtocol.encodeStateAsUpdate(docA); - final updateB = SynkProtocol.encodeStateAsUpdate(docB); - - SynkProtocol.applyUpdate(docB, updateA); - SynkProtocol.applyUpdate(docA, updateB); - - // Client 200 > Client 100, so Bob's `false` should win the tie! - expect(flagA.value, isFalse); - expect(flagB.value, isFalse); - }); - - test('initializes correctly from pre-existing timeline', () { - final doc = SynkDoc(); - final flag1 = SynkBool(doc, 'isActive'); - flag1.set(true); - - // Create a second flag bound to the same name AFTER operations - // occurred - final flag2 = SynkBool(doc, 'isActive'); - expect(flag2.value, isTrue); - }); - - test('dispose() correctly unregisters listeners', () { - final doc = SynkDoc(); - final flag = SynkBool(doc, 'isActive'); - - flag.set(true); - expect(flag.value, isTrue); - - flag.dispose(); - - flag.set(false); - expect(flag.value, isTrue); - }); - }); -} diff --git a/test/types/synk_double_test.dart b/test/types/synk_double_test.dart deleted file mode 100644 index 99c0813..0000000 --- a/test/types/synk_double_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -// ignore_for_file: prefer_int_literals, cascade_invocations - -import 'package:synk/synk.dart'; -import 'package:test/test.dart'; - -void main() { - group('SynkDouble', () { - test('initializes to 0.0', () { - final doc = SynkDoc(); - final weight = SynkDouble(doc, 'weight'); - expect(weight.value, equals(0.0)); - }); - - test('sets correctly locally', () { - final doc = SynkDoc(); - final weight = SynkDouble(doc, 'weight'); - - weight.set(75.5); - expect(weight.value, equals(75.5)); - }); - - test('gracefully handles integer casting internally', () { - final doc = SynkDoc(); - final weight = SynkDouble(doc, 'weight'); - - // When sending json encoded payload, 75.0 can become 75 (int). - // Our implementation does `(content as num).toDouble()` - // Let's test by manually injecting an int item. - doc.transact((txn) { - doc.addItem( - Item( - id: txn.getNextId(), - parentKey: 'weight', - content: 42, // pure int - ), - ); - }); - - expect(weight.value, equals(42.0)); - }); - - test('LWW resolves conflicts correctly', () { - final docA = SynkDoc(clientId: 1); - final docB = SynkDoc(clientId: 2); - - final sliderA = SynkDouble(docA, 'slider'); - final sliderB = SynkDouble(docB, 'slider'); - - // Alice's clock hits 1 - sliderA.set(1.0); - sliderA.set(2.0); - - // Bob's clock hits 0 - sliderB.set(0.5); - - final updateA = SynkProtocol.encodeStateAsUpdate(docA); - final updateB = SynkProtocol.encodeStateAsUpdate(docB); - - SynkProtocol.applyUpdate(docB, updateA); - SynkProtocol.applyUpdate(docA, updateB); - - // Alice's clock (1) > Bob's clock (0), so Alice wins - expect(sliderA.value, equals(2.0)); - expect(sliderB.value, equals(2.0)); - }); - - test('initializes correctly from pre-existing timeline', () { - final doc = SynkDoc(); - final weight1 = SynkDouble(doc, 'weight'); - weight1.set(42.5); - - // Create a second weight bound to the same name AFTER operations - // occurred - final weight2 = SynkDouble(doc, 'weight'); - expect(weight2.value, equals(42.5)); - }); - - test('dispose() correctly unregisters listeners', () { - final doc = SynkDoc(); - final weight = SynkDouble(doc, 'weight'); - - weight.set(42.5); - expect(weight.value, equals(42.5)); - - weight.dispose(); - - // This update should NOT be processed by the disposed map - weight.set(100); - - // It should still have the old value because the listener was removed - expect(weight.value, equals(42.5)); - }); - }); -} diff --git a/test/types/synk_string_test.dart b/test/types/synk_string_test.dart deleted file mode 100644 index 0905298..0000000 --- a/test/types/synk_string_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:synk/synk.dart'; -import 'package:test/test.dart'; - -void main() { - group('SynkString', () { - test('initializes to empty string', () { - final doc = SynkDoc(); - final title = SynkString(doc, 'title'); - expect(title.value, equals('')); - }); - - test('sets correctly locally', () { - final doc = SynkDoc(); - final title = SynkString(doc, 'title'); - - title.set('Hello World'); - expect(title.value, equals('Hello World')); - - title.set('Updated'); - expect(title.value, equals('Updated')); - }); - - test('LWW resolves conflicts correctly', () { - final docA = SynkDoc(clientId: 1); - final docB = SynkDoc(clientId: 2); - - final titleA = SynkString(docA, 'title'); - final titleB = SynkString(docB, 'title'); - - // Alice's clock hits 1 - titleA.set('A1'); - titleA.set('A2'); - - // Bob's clock hits 0 - titleB.set('B1'); - - final updateA = SynkProtocol.encodeStateAsUpdate(docA); - final updateB = SynkProtocol.encodeStateAsUpdate(docB); - - SynkProtocol.applyUpdate(docB, updateA); - SynkProtocol.applyUpdate(docA, updateB); - - // Alice's clock (1) > Bob's clock (0), so Alice wins - expect(titleA.value, equals('A2')); - expect(titleB.value, equals('A2')); - }); - - test('initializes correctly from pre-existing timeline', () { - final doc = SynkDoc(); - final title1 = SynkString(doc, 'title'); - title1.set('Hello'); - - // Create a second string bound to the same name AFTER operations - // occurred - final title2 = SynkString(doc, 'title'); - expect(title2.value, equals('Hello')); - }); - - test('dispose() correctly unregisters listeners', () { - final doc = SynkDoc(); - final title = SynkString(doc, 'title'); - - title.set('Hello'); - expect(title.value, equals('Hello')); - - title.dispose(); - - // This update should NOT be processed by the disposed map - title.set('Goodbye'); - - // It should still have the old value because the listener was removed - expect(title.value, equals('Hello')); - }); - }); -} diff --git a/test/types/synk_value_test.dart b/test/types/synk_value_test.dart new file mode 100644 index 0000000..c871108 --- /dev/null +++ b/test/types/synk_value_test.dart @@ -0,0 +1,125 @@ +// ignore_for_file: cascade_invocations + +import 'package:synk/synk.dart'; +import 'package:test/test.dart'; + +void main() { + group('SynkValue', () { + test('String -> resolves concurrent edits using LWW (clock wins)', () { + final doc1 = SynkDoc(clientId: 1); + final doc2 = SynkDoc(clientId: 2); + + final title1 = SynkValue(doc1, 'title'); + final title2 = SynkValue(doc2, 'title'); + + // Simulate isolated concurrent edits + title1.set('Alpha'); + title2.set('Beta'); + + // Now sync them + final update1 = SynkProtocol.encodeStateAsUpdate(doc1); + final update2 = SynkProtocol.encodeStateAsUpdate(doc2); + + SynkProtocol.applyUpdate(doc2, update1); + SynkProtocol.applyUpdate(doc1, update2); + + // Both should resolve to 'Beta' because Doc 2 has the higher clientId + // when clocks are completely equal (clock 0 relative to their own state). + expect(title1.value, equals('Beta')); + expect(title2.value, equals('Beta')); + }); + + test('double -> resolves identical clock operations', () { + final doc1 = SynkDoc(clientId: 1); + final doc2 = SynkDoc(clientId: 2); + + final opacity1 = SynkValue(doc1, 'opacity'); + final opacity2 = SynkValue(doc2, 'opacity'); + + opacity1.set(0.5); + opacity2.set(0.8); + + final u1 = SynkProtocol.encodeStateAsUpdate(doc1); + final u2 = SynkProtocol.encodeStateAsUpdate(doc2); + + SynkProtocol.applyUpdate(doc2, u1); + SynkProtocol.applyUpdate(doc1, u2); + + // clientId 2 wins + expect(opacity1.value, equals(0.8)); + expect(opacity2.value, equals(0.8)); + }); + + test('bool -> honors clock superiority', () { + final doc = SynkDoc(clientId: 1); + final flag = SynkValue(doc, 'ready'); + expect(flag.value, isNull); + + flag.set(false); // Clock 0 + flag.set(true); // Clock 1 + expect(flag.value, isTrue); + + // Simulate a highly delayed packet from a completely different doc + doc.transact((txn) { + doc.addItem( + Item( + // Clock 0 is strictly older than our current clock 1 + id: const ID(55, 0), + parentKey: 'ready', + content: false, + ), + ); + }); + + // It should still be true because our local clock was strictly higher + expect(flag.value, isTrue); + }); + + test('initialize with historic data', () { + final doc = SynkDoc(clientId: 1); + doc.transact((txn) { + doc.addItem( + Item(id: txn.getNextId(), content: 100, parentKey: 'price'), + ); + }); + + // Initializes after the item is already present + final price = SynkValue(doc, 'price'); + expect(price.value, equals(100)); + }); + + test('dispose() correctly unregisters listeners', () { + final doc = SynkDoc(); + final mode = SynkValue(doc, 'mode'); + + mode.set('dark'); + expect(mode.value, equals('dark')); + + mode + ..dispose() + ..set('system'); + + // It should still have the old value because the listener was removed + expect(mode.value, equals('dark')); + }); + + test('double -> handles integer values from the wire (JSON lossiness)', () { + final doc = SynkDoc(clientId: 1); + final opacity = SynkValue(doc, 'opacity'); + + // Simulate a remote item where a double was encoded as an int in JSON + doc.transact((txn) { + doc.addItem( + Item( + id: const ID(2, 0), + parentKey: 'opacity', + content: 1, // Int content + ), + ); + }); + + expect(opacity.value, isA()); + expect(opacity.value, equals(1.0)); + }); + }); +}