Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
920fe0d
:white_check_mark: add unit tests for ListFormat generators
techouse Sep 26, 2025
49eca19
:white_check_mark: add unit tests for Utils.merge, encode, and helper…
techouse Sep 26, 2025
162cdd7
:white_check_mark: add test for legacy decoder fallback in DecodeOptions
techouse Sep 26, 2025
f3e2e4d
:recycle: improve boundary string construction in utils_additional_test
techouse Sep 26, 2025
4b48728
:white_check_mark: add test for deprecated decoder in DecodeOptions
techouse Sep 26, 2025
1731596
:white_check_mark: add targeted unit tests for QS.decode edge cases
techouse Sep 26, 2025
3665669
:white_check_mark: add targeted unit tests for QS.decode edge cases
techouse Sep 26, 2025
d30db5f
:white_check_mark: add tests for Utils.merge and encode surrogate han…
techouse Sep 26, 2025
fb64587
:white_check_mark: add test for merging maps with scalar targets in U…
techouse Sep 26, 2025
3eb8a1e
:recycle: refactor encode logic to handle dot encoding for root primi…
techouse Sep 26, 2025
faf59cd
:white_check_mark: add tests for decode depth remainder wrapping scen…
techouse Sep 26, 2025
e42be8f
:white_check_mark: add tests for encoding edge cases in QS.encode
techouse Sep 26, 2025
a4d8cae
:white_check_mark: add additional tests for QS.encode covering empty …
techouse Sep 26, 2025
4775b94
:white_check_mark: add tests for surrogate and charset edge cases in …
techouse Sep 26, 2025
5d8c95e
:rewind: revert changes
techouse Sep 26, 2025
f90c1db
:white_check_mark: add tests for MapBase implementation with throwing…
techouse Sep 26, 2025
62e82a0
:white_check_mark: update test for QS.encode to allow flexible encodi…
techouse Sep 26, 2025
e5b089d
:pencil2: change return type of operator [] to non-nullable String in…
techouse Sep 26, 2025
64bca00
:white_check_mark: update tests in encode_edge_cases_test.dart to use…
techouse Sep 26, 2025
7e11b30
:white_check_mark: update tests in encode_edge_cases_test.dart to ver…
techouse Sep 26, 2025
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
107 changes: 107 additions & 0 deletions test/unit/decode_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2792,4 +2792,111 @@ void main() {
);
});
});

group('Targeted coverage additions', () {
test('comma splitting truncates to remaining list capacity', () {
final result = QS.decode(
'a=1,2,3',
const DecodeOptions(comma: true, listLimit: 2),
);

final Iterable<dynamic> iterable = result['a'] as Iterable;
expect(iterable.toList(), equals(['1', '2']));
});

test('comma splitting throws when limit exceeded in strict mode', () {
expect(
() => QS.decode(
'a=1,2',
const DecodeOptions(
comma: true,
listLimit: 1,
throwOnLimitExceeded: true,
),
),
throwsA(isA<RangeError>()),
);
});

test('strict depth throws when additional bracket groups remain', () {
expect(
() => QS.decode(
'a[b][c][d]=1',
const DecodeOptions(depth: 2, strictDepth: true),
),
throwsA(isA<RangeError>()),
);
});

test('non-strict depth keeps remainder as literal bracket segment', () {
final decoded = QS.decode(
'a[b][c][d]=1',
const DecodeOptions(depth: 2),
);

expect(
decoded,
equals({
'a': {
'b': {
'c': {'[d]': '1'}
}
}
}));
});

test('parameterLimit < 1 coerces to zero and triggers argument error', () {
expect(
() => QS.decode(
'a=b',
const DecodeOptions(parameterLimit: 0.5),
),
throwsA(isA<ArgumentError>()),
);
});

test('allowDots accepts hyphen-prefixed segments as identifiers', () {
expect(
QS.decode('a.-foo=1', const DecodeOptions(allowDots: true)),
equals({
'a': {'-foo': '1'}
}),
);
});

test('allowDots keeps literal dot when segment start is not identifier',
() {
expect(
QS.decode('a.@foo=1', const DecodeOptions(allowDots: true)),
equals({'a.@foo': '1'}),
);
});
});

group('decode depth remainder wrapping (coverage)', () {
test(
'wraps excess groups when strictDepth=false (value nested under grouped remainder)',
() {
final result = QS.decode(
'a[b][c][d]=1', const DecodeOptions(depth: 2, strictDepth: false));
// Structure: a -> b -> c -> [d] = 1 (where [d] literal becomes key '[d]').
final a = result['a'] as Map;
final b = a['b'] as Map;
final c = b['c'] as Map; // remainder first group 'c'
final dContainer = c['[d]'];
expect(dContainer, '1');
});

test(
'trailing text after last group captured as nested remainder structure',
() {
final result = QS.decode(
'a[b]tail=1', const DecodeOptions(depth: 1, strictDepth: false));
// depth=1 gives segments: 'a' plus wrapped remainder starting at '[b]'.
final a = result['a'] as Map;
// Remainder yields 'b' -> {'tail': '1'}
final bMap = a['b'] as Map;
expect((bMap['tail']), '1');
});
});
}
172 changes: 172 additions & 0 deletions test/unit/encode_edge_cases_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import 'dart:collection';
import 'dart:convert' show Encoding;

import 'package:qs_dart/qs_dart.dart';
import 'package:test/test.dart';

// Map-like test double that throws when accessing the 'boom' key to exercise the
// try/catch undefined path in the encoder's value resolution logic.
class _Dyn extends MapBase<String, dynamic> {
final Map<String, dynamic> _store = {'ok': 42};

@override
dynamic operator [](Object? key) {
if (key == 'boom') throw ArgumentError('boom');
return _store[key];
}

@override
void operator []=(String key, dynamic value) => _store[key] = value;

@override
void clear() => _store.clear();

@override
Iterable<String> get keys => _store.keys;

@override
dynamic remove(Object? key) => _store.remove(key);

@override
bool containsKey(Object? key) => _store.containsKey(key);

// Explicit length getter (not abstract in MapBase but included for clarity / coverage intent)
@override
int get length => _store.length;
}

void main() {
group('encode edge cases', () {
test('cycle detection: shared subobject visited twice without throwing',
() {
final shared = {'z': 1};
final obj = {'a': shared, 'b': shared};
// Encoded output will have two key paths referencing the same subobject; no RangeError.
final encoded = QS.encode(obj);
// Accept either ordering; just verify two occurrences of '=1'.
final occurrences = '=1'.allMatches(encoded).length;
expect(occurrences, 2);
});

test('strictNullHandling with custom encoder emits only encoded key', () {
final encoded = QS.encode(
{
'nil': null,
},
const EncodeOptions(
strictNullHandling: true, encoder: _identityEncoder));
// Expect just the key without '=' (qs semantics) – no trailing '=' segment.
expect(encoded, 'nil');
});

test('filter iterable branch on MapBase with throwing key access', () {
final dyn = _Dyn();
// Encode the MapBase directly with a filter that forces lookups for both 'ok'
// (successful) and 'boom' (throws → caught → undefined + skipped by skipNulls).
final encoded = QS.encode(
dyn, const EncodeOptions(filter: ['ok', 'boom'], skipNulls: true));
expect(encoded, 'ok=42');
});

test('comma list empty emits nothing but executes Undefined sentinel path',
() {
final encoded = QS.encode(
{'list': <String>[]},
const EncodeOptions(
listFormat: ListFormat.comma, allowEmptyLists: false));
// Empty under comma + allowEmptyLists=false → nothing emitted.
expect(encoded, isEmpty);
});

test(
'cycle detection non-direct: shared object at different depths (pos != step path)',
() {
final shared = {'k': 'v'};
final obj = {
'a': {'x': shared},
'b': {
'y': {'z': shared}
},
};
final encoded = QS.encode(obj);
// Two serialized occurrences with percent-encoded brackets.
expect(
RegExp(r'a%5Bx%5D%5Bk%5D=v', caseSensitive: false).hasMatch(encoded),
isTrue,
);
expect(
RegExp(r'b%5By%5D%5Bz%5D%5Bk%5D=v', caseSensitive: false)
.hasMatch(encoded),
isTrue,
);
});

test(
'strictNullHandling nested null returns prefix string (non-iterable recursion branch)',
() {
final encoded = QS.encode({
'p': {'c': null}
}, const EncodeOptions(strictNullHandling: true));
// Brackets are percent-encoded in final output.
expect(encoded.contains('p%5Bc%5D'), isTrue);
expect(encoded.contains('p%5Bc%5D='), isFalse);
});

test(
'strictNullHandling + custom mutating encoder transforms key (encoder ternary branch)',
() {
// Encoder mutates keys by wrapping them; value is null so only key is emitted.
final encoded = QS.encode(
{'nil': null},
const EncodeOptions(
strictNullHandling: true,
encoder: _mutatingEncoder,
));
expect(encoded, 'X_nil');
});

test(
'allowEmptyLists nested empty list returns scalar fragment to parent (flatten branch)',
() {
final encoded = QS.encode({
'outer': {'p': []}
}, const EncodeOptions(allowEmptyLists: true));
// Allow either percent-encoded or raw bracket form (both are acceptable depending on encoding path),
// and an optional trailing '=' if future changes emit an explicit empty value.
final pattern = RegExp(r'^(outer%5Bp%5D%5B%5D=?|outer\[p\]\[\](=?))$');
expect(encoded, matches(pattern));
});

test('cycle detection step reset path (multi-level shared object)', () {
// Construct a deeper object graph where the same shared leaf appears in
// branches of differing depth to exercise the while-loop step reset logic.
final shared = {'leaf': 1};
final obj = {
'a': {
'l1': {'l2': shared}
},
'b': {
'l1': {
'l2': {
'l3': {'l4': shared}
}
}
},
'c': 2,
};
final encoded = QS.encode(obj);
// Two occurrences of the shared leaf serialization plus the scalar 'c'.
final occurrences = 'leaf%5D=1'
.allMatches(encoded)
.length; // pattern like a%5Bl1%5D%5Bl2%5D%5Bleaf%5D=1
expect(occurrences, 2);
expect(encoded.contains('c=2'), isTrue);
});
});
}

String _identityEncoder(dynamic v, {Encoding? charset, Format? format}) =>
v?.toString() ?? '';

String _mutatingEncoder(dynamic v, {Encoding? charset, Format? format}) =>
'X_${v.toString()}';
Loading