diff --git a/lib/features/redis/redis_key_tree_engine.dart b/lib/features/redis/redis_key_tree_engine.dart index 2185d7f6..e39a90de 100644 --- a/lib/features/redis/redis_key_tree_engine.dart +++ b/lib/features/redis/redis_key_tree_engine.dart @@ -50,7 +50,15 @@ class RedisKeyFolderNode { final List leaves; /// Total count of keys in this subtree (both direct leaves and nested leaves). - int get totalKeyCount { + /// + /// Counted on first use and kept: the folder row asks for it on every + /// rebuild, and a walk of the whole subtree each time costs O(keys x depth). + /// The tree is not changed once the engine returns it, so the count stays + /// right; it is lazy so a node whose lists are filled after construction + /// still counts what is in them at first use. + late final int totalKeyCount = _countKeys(); + + int _countKeys() { var count = leaves.length; for (final f in folders) { count += f.totalKeyCount; diff --git a/lib/features/workspace/grid_groupings_engine.dart b/lib/features/workspace/grid_groupings_engine.dart index 215689ea..a3364366 100644 --- a/lib/features/workspace/grid_groupings_engine.dart +++ b/lib/features/workspace/grid_groupings_engine.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; /// Aggregation operation to perform on groups. enum GroupingAggType { @@ -194,8 +195,15 @@ abstract final class GridGroupingsEngine { for (final row in rows) { if (targetCol < row.length) { - final val = row[targetCol].replaceAll(',', '').trim(); - final parsed = double.tryParse(val); + // `trim()` hands back the cell itself when there is nothing to trim, + // and the comma-aware parse does not copy it either. Anything it + // cannot read takes the old copy, so the result is the same as + // `double.tryParse(cell.replaceAll(',', '').trim())`. + final cell = row[targetCol].trim(); + final parsed = cell.contains(',') + ? GridSelectionCalcEngine.parseThousandsSeparated(cell) ?? + double.tryParse(cell.replaceAll(',', '').trim()) + : double.tryParse(cell); if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { numbers.add(parsed); } diff --git a/lib/features/workspace/grid_selection_calc_engine.dart b/lib/features/workspace/grid_selection_calc_engine.dart index 633c7923..6552a432 100644 --- a/lib/features/workspace/grid_selection_calc_engine.dart +++ b/lib/features/workspace/grid_selection_calc_engine.dart @@ -267,7 +267,6 @@ abstract final class GridSelectionCalcEngine { /// exact: an integer below 2^53 times or divided by an exactly represented /// power of ten is one correctly rounded operation. Longer numbers, which /// do not fit that, take the copy and `double.tryParse`. - @foundation.visibleForTesting static double? parseThousandsSeparated(String s) { final n = s.length; var i = 0; diff --git a/test/features/workspace/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart index dd64ac11..002d34ee 100644 --- a/test/features/workspace/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -371,6 +371,24 @@ void main() { expect(activeAvg.aggValue, equals(200.0)); }); + test('aggregates numbers written with thousands separators', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [0], + rows: const [ + ['a', '1,234.5'], + ['a', ' 2,000 '], + ['a', '1,2,3'], // commas count for nothing: 123 + ['a', 'n/a, see note'], // not a number: skipped + ['a', '10'], + ], + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 1, + ), + ); + expect(groups.single.aggValue, closeTo(1234.5 + 2000 + 123 + 10, 1e-9)); + }); + test('sorts groups by key or aggregate', () { final keySorted = GridGroupingsEngine.buildGroups( groupColIndices: [1],