Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions lib/store/path_ref_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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]--;
}
Comment on lines +112 to 117
}

Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions test/core/store/path_ref_store_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
185 changes: 185 additions & 0 deletions test/core/store/store_property_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <String>[
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 = <String>[]; // inc'd paths, with multiplicity
final ops = <String>[];

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<int>();
final model = <String, int>{};
final ops = <String>[];
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 = <String, int>{};
for (final entry in model.entries) {
if (_parent(entry.key) == q) {
expected[_lastSegment(entry.key)] = entry.value;
}
}
final actual = store.getChildValues(q) ?? const <String, int>{};
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<String>();
final model = <String, String>{};
final ops = <String>[];

// getRefs(path) aggregates values strictly under a node; for the root
// path it aggregates every value in the store.
Map<String, int> refsUnder(String path) {
final counts = <String, int>{};
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 <String, int>{};
expect(Map<String, int>.from(actual), equals(expected),
reason: '$reason getRefs("$q")');
expect(store.extractValues(q), equals(expected.keys.toSet()),
reason: '$reason extractValues("$q")');
}
}
}
});
});
}
Loading