diff --git a/lib/store/path_ref_store.dart b/lib/store/path_ref_store.dart index d03e9fe..32aaa6a 100644 --- a/lib/store/path_ref_store.dart +++ b/lib/store/path_ref_store.dart @@ -109,12 +109,11 @@ class PathRefStore { node[segment]--; } } else if (node[segment] is Map) { - // If this is the last reference to the child node, then remove the ref key from the child, - // marking that it is now purely a transient node. - if (node[segment][_refKey] == 1) { - return true; + final Map child = node[segment]; + if (child[_refKey] == 1) { + node.remove(segment); } else { - node[segment][_refKey]--; + child[_refKey]--; } } @@ -125,9 +124,6 @@ class PathRefStore { /// Decrements the ref count to the node at the given path, removing it if it was the last reference to the node. void dec(String path) { - // `_dec` assumes every node it walks carries a `_refKey`; without this - // guard an untracked path would still decrement (and potentially clear) - // ancestors that share a prefix. if (!has(path)) { return; } @@ -152,15 +148,14 @@ class PathRefStore { return node.containsKey(segment); } - /// Returns whether the path exists in the store. + /// Returns whether the path or any descendant path exists in the store. /// /// Example: /// ```dart /// final refStore = PathRefStore(); /// refStore.inc('users__1__posts__2'); /// refStore.has('users__1__posts__2') // true - /// refStore.has('users__1') // false - /// refStore.hasPath('users__1') // true + /// refStore.has('users__1') // true /// /// ``` bool has(String path) { diff --git a/test/core/store/path_ref_store_test.dart b/test/core/store/path_ref_store_test.dart index 8835170..1d598be 100644 --- a/test/core/store/path_ref_store_test.dart +++ b/test/core/store/path_ref_store_test.dart @@ -171,6 +171,24 @@ void main() { }); }); + test('Dec of a map-backed path removes the empty node', () { + // The extra live sibling keeps the root ref count above 1 so this + // exercises the child-map branch rather than the root clear path. + final store = PathRefStore(); + store.inc('c'); + store.inc('c__c'); + store.dec('c__c'); + store.inc('a'); + + store.dec('c'); + + expect(store.has('c'), false); + expect(store.inspect(), { + "__ref": 1, + "a": 1, + }); + }); + test('Dec of untracked deep path under a tracked node is a no-op', () { final store = PathRefStore(); store.inc('a__b__c'); diff --git a/test/core/store/store_property_test.dart b/test/core/store/store_property_test.dart new file mode 100644 index 0000000..2fd7d53 --- /dev/null +++ b/test/core/store/store_property_test.dart @@ -0,0 +1,185 @@ +import 'dart:math'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:loon/loon.dart'; + +/// Model-based property tests for the path-keyed store structures. +/// +/// Each test drives randomized operation sequences against the real store and +/// a trivial reference model, then asserts the two agree (and that structural +/// invariants like "empties out completely" hold). The reference results are +/// recomputed from scratch each step, so they're an independent oracle for the +/// stores' incremental bookkeeping. On failure the seed and operation log are +/// printed so the case can be replayed. +/// +/// A small segment/value alphabet is used deliberately to force path collisions +/// and shared prefixes, which is where the tree-restructuring edge cases live. +/// +/// Scope: covers `write` and recursive `delete`. Non-recursive `delete` has +/// subtler semantics (it prunes a node's immediate values while keeping deeper +/// descendants) and is left for a dedicated model. +const _d = '__'; +const _alphabet = ['a', 'b', 'c']; + +String _randomPath(Random r) { + final depth = 1 + r.nextInt(3); // 1..3 segments + return List.generate(depth, (_) => _alphabet[r.nextInt(_alphabet.length)]) + .join(_d); +} + +/// All non-empty paths of depth 1 and 2 — a fixed grid of query points. +final _grid = [ + for (final a in _alphabet) ...[ + a, + for (final b in _alphabet) '$a$_d$b', + ], +]; + +/// Whether [key] is at or below [path] in the tree. +bool _atOrUnder(String key, String path) => + path.isEmpty || key == path || key.startsWith('$path$_d'); + +String _parent(String path) { + final i = path.lastIndexOf(_d); + return i == -1 ? '' : path.substring(0, i); +} + +String _lastSegment(String path) { + final i = path.lastIndexOf(_d); + return i == -1 ? path : path.substring(i + _d.length); +} + +void main() { + group('PathRefStore property', () { + test('matches reference model across random inc/dec sequences', () { + for (var seed = 0; seed < 200; seed++) { + final r = Random(seed); + final store = PathRefStore(); + final live = []; // inc'd paths, with multiplicity + final ops = []; + + for (var step = 0; step < 50; step++) { + // dec only targets paths that were actually inc'd, mirroring how the + // library pairs inc/dec; bias toward inc when nothing is live. + if (live.isEmpty || r.nextBool()) { + final p = _randomPath(r); + store.inc(p); + live.add(p); + ops.add('inc($p)'); + } else { + final p = live.removeAt(r.nextInt(live.length)); + store.dec(p); + ops.add('dec($p)'); + } + + final reason = 'seed=$seed ops=$ops'; + for (final q in {..._grid, ...live}) { + // has(q): true iff some live path is q or a descendant of q. + final expected = live.any((p) => _atOrUnder(p, q)); + expect(store.has(q), expected, reason: '$reason has($q)'); + } + expect(store.isEmpty, live.isEmpty, reason: '$reason isEmpty'); + } + } + }); + }); + + group('ValueStore property', () { + test('matches reference model across random write/delete sequences', () { + for (var seed = 0; seed < 200; seed++) { + final r = Random(seed); + final store = ValueStore(); + final model = {}; + final ops = []; + var counter = 0; + + for (var step = 0; step < 50; step++) { + if (r.nextInt(3) != 0) { + final p = _randomPath(r); + final v = counter++; + store.write(p, v); + model[p] = v; + ops.add('write($p,$v)'); + } else { + final p = _randomPath(r); + store.delete(p); // recursive + model.removeWhere((k, _) => _atOrUnder(k, p)); + ops.add('delete($p)'); + } + + final reason = 'seed=$seed ops=$ops'; + for (final q in {..._grid, ...model.keys}) { + expect(store.get(q), model[q], reason: '$reason get($q)'); + expect(store.hasValue(q), model.containsKey(q), + reason: '$reason hasValue($q)'); + final hasPath = model.containsKey(q) || + model.keys.any((k) => k.startsWith('$q$_d')); + expect(store.hasPath(q), hasPath, reason: '$reason hasPath($q)'); + } + for (final q in _grid) { + final expected = {}; + for (final entry in model.entries) { + if (_parent(entry.key) == q) { + expected[_lastSegment(entry.key)] = entry.value; + } + } + final actual = store.getChildValues(q) ?? const {}; + expect(actual, equals(expected), + reason: '$reason getChildValues($q)'); + } + expect(store.isEmpty, model.isEmpty, reason: '$reason isEmpty'); + } + } + }); + }); + + group('ValueRefStore property', () { + test('ref aggregation matches the values in each subtree', () { + for (var seed = 0; seed < 200; seed++) { + final r = Random(seed); + final store = ValueRefStore(); + final model = {}; + final ops = []; + + // getRefs(path) aggregates values strictly under a node; for the root + // path it aggregates every value in the store. + Map refsUnder(String path) { + final counts = {}; + for (final entry in model.entries) { + final under = + path.isEmpty ? true : entry.key.startsWith('$path$_d'); + if (under) { + counts[entry.value] = (counts[entry.value] ?? 0) + 1; + } + } + return counts; + } + + for (var step = 0; step < 50; step++) { + if (r.nextInt(3) != 0) { + final p = _randomPath(r); + // Small value space → many shared refs to stress the aggregation. + final v = _alphabet[r.nextInt(_alphabet.length)]; + store.write(p, v); + model[p] = v; + ops.add('write($p,$v)'); + } else { + final p = _randomPath(r); + store.delete(p); // recursive + model.removeWhere((k, _) => _atOrUnder(k, p)); + ops.add('delete($p)'); + } + + final reason = 'seed=$seed ops=$ops'; + for (final q in ['', ..._grid]) { + final expected = refsUnder(q); + final actual = store.getRefs(q) ?? const {}; + expect(Map.from(actual), equals(expected), + reason: '$reason getRefs("$q")'); + expect(store.extractValues(q), equals(expected.keys.toSet()), + reason: '$reason extractValues("$q")'); + } + } + } + }); + }); +}