diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 63b6aca..454eae0 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -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 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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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'); + }); + }); } diff --git a/test/unit/encode_edge_cases_test.dart b/test/unit/encode_edge_cases_test.dart new file mode 100644 index 0000000..d0e27ef --- /dev/null +++ b/test/unit/encode_edge_cases_test.dart @@ -0,0 +1,171 @@ +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 { + final Map _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 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); + expect(encoded.contains('a%5Bz%5D=1'), isTrue); + expect(encoded.contains('b%5Bz%5D=1'), isTrue); + }); + + 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': []}, + 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}) => + v == null ? '' : 'X_${v.toString()}'; diff --git a/test/unit/encode_test.dart b/test/unit/encode_test.dart index ce5fad1..dfa3d78 100644 --- a/test/unit/encode_test.dart +++ b/test/unit/encode_test.dart @@ -13,11 +13,14 @@ import '../fixtures/dummy_enum.dart'; // Custom class that is neither a Map nor an Iterable class CustomObject { - final String value; - CustomObject(this.value); - String? operator [](String key) => key == 'prop' ? value : null; + final String value; + + String operator [](String key) { + if (key == 'prop') return value; + throw UnsupportedError('Only prop supported'); + } } void main() { @@ -91,55 +94,19 @@ void main() { result2, 'dates=2023-01-01T00:00:00.000Z,2023-01-01T00:00:00.000Z'); }); - test('Access property of non-Map, non-Iterable object', () { - // This test targets line 161 in encode.dart - // Create a custom object that's neither a Map nor an Iterable + test('filter callback can expand custom objects into maps', () { final customObj = CustomObject('test'); - // Create a test that will try to access a property of the custom object - // We need to modify our approach to ensure the code path is exercised - - // First, let's verify that our CustomObject works as expected - expect(customObj['prop'], equals('test')); - - // Now, let's create a test that will try to access the property - // We'll use a different approach that's more likely to exercise the code path - try { - final result = QS.encode( - {'obj': customObj}, - const EncodeOptions(encode: false), - ); - - // The result might be empty, but the important thing is that the code path is executed - expect(result.isEmpty, isTrue); - } catch (e) { - // If an exception is thrown, that's also fine as long as the code path is executed - // We're just trying to increase coverage, not test functionality - } - - // Try another approach with a custom filter - try { - final result = QS.encode( - {'obj': customObj}, - EncodeOptions( - encode: false, - filter: (prefix, value) { - // This should trigger the code path that accesses properties of non-Map, non-Iterable objects - if (value is CustomObject) { - return value['prop']; - } - return value; - }, - ), - ); + final result = QS.encode( + {'obj': customObj}, + EncodeOptions( + encode: false, + filter: (prefix, value) => + value is CustomObject ? {'prop': value.value} : value, + ), + ); - // The result might vary, but the important thing is that the code path is executed - // Check if the result contains the expected value - expect(result, contains('obj=test')); - } catch (e) { - // If an exception is thrown, that's also fine as long as the code path is executed - // Exception: $e - } + expect(result, equals('obj[prop]=test')); }); test('encodes a query string map', () { expect(QS.encode({'a': 'b'}), equals('a=b')); @@ -5269,4 +5236,37 @@ void main() { ); }); }); + + group('Additional encode coverage', () { + test('empty list with allowEmptyLists emits key[]', () { + expect( + QS.encode({'a': []}, + const EncodeOptions(allowEmptyLists: true, encode: false)), + 'a[]', + ); + }); + + test('commaRoundTrip single item list adds []', () { + expect( + QS.encode( + { + 'a': ['x'] + }, + const EncodeOptions( + listFormat: ListFormat.comma, + commaRoundTrip: true, + encode: false)), + 'a[]=x', + ); + }); + + test('cycle detection throws RangeError', () { + final map = {}; + map['self'] = map; // self reference + expect( + () => QS.encode(map), + throwsA(isA()), + ); + }); + }); } diff --git a/test/unit/list_format_test.dart b/test/unit/list_format_test.dart new file mode 100644 index 0000000..65f43c1 --- /dev/null +++ b/test/unit/list_format_test.dart @@ -0,0 +1,26 @@ +import 'package:qs_dart/qs_dart.dart'; +import 'package:test/test.dart'; + +void main() { + group('ListFormat generators', () { + test('brackets format appends empty brackets', () { + expect(ListFormat.brackets.generator('foo'), equals('foo[]')); + }); + + test('comma format keeps prefix untouched', () { + expect(ListFormat.comma.generator('foo'), equals('foo')); + }); + + test('repeat format reuses the prefix', () { + expect(ListFormat.repeat.generator('foo'), equals('foo')); + }); + + test('indices format injects the element index', () { + expect(ListFormat.indices.generator('foo', '2'), equals('foo[2]')); + }); + + test('toString mirrors enum name', () { + expect(ListFormat.indices.toString(), equals('indices')); + }); + }); +} diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 6227897..872954d 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -332,4 +332,34 @@ void main() { ))); }); }); + + group('DecodeOptions legacy decoder fallback', () { + test('prefers legacy decoder when primary decoder absent', () { + final calls = >[]; + final opts = DecodeOptions( + legacyDecoder: (String? value, {Encoding? charset}) { + calls.add({'value': value, 'charset': charset}); + return value?.toUpperCase(); + }, + ); + + expect(opts.decode('abc', charset: latin1), equals('ABC')); + expect(calls, hasLength(1)); + expect(calls.single['charset'], equals(latin1)); + }); + + test('deprecated decoder forwards to decode implementation', () { + final opts = DecodeOptions( + decoder: (String? value, {Encoding? charset, DecodeKind? kind}) => + 'kind=$kind,value=$value,charset=$charset', + ); + + expect( + opts.decoder('foo', charset: latin1, kind: DecodeKind.key), + equals( + 'kind=${DecodeKind.key},value=foo,charset=Instance of \'Latin1Codec\'', + ), + ); + }); + }); } diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index ade1570..71a3e00 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: deprecated_member_use_from_same_package +import 'dart:collection'; import 'dart:convert' show latin1, utf8; import 'package:qs_dart/qs_dart.dart'; @@ -1159,5 +1160,138 @@ void main() { expect(identical(out['a'], out['b']), isTrue); }); }); + + group('utils surrogate and charset edge cases (coverage)', () { + test('lone high surrogate encoded as three UTF-8 bytes', () { + const loneHigh = '\uD800'; + final encoded = QS.encode({'s': loneHigh}); + // Expect percent encoded sequence %ED%A0%80 + expect(encoded, contains('%ED%A0%80')); + }); + + test('lone low surrogate encoded as three UTF-8 bytes', () { + const loneLow = '\uDC00'; + final encoded = QS.encode({'s': loneLow}); + expect(encoded, contains('%ED%B0%80')); + }); + + test('latin1 decode leaves invalid escape intact', () { + final decoded = + QS.decode('a=%ZZ&b=%41', const DecodeOptions(charset: latin1)); + expect(decoded['a'], '%ZZ'); + expect(decoded['b'], 'A'); + }); + }); + + group('Utils.merge edge branches', () { + test('normalizes to map when Undefined persists and parseLists is false', + () { + final result = Utils.merge( + [const Undefined()], + const [Undefined()], + const DecodeOptions(parseLists: false), + ); + + final splay = result as SplayTreeMap; + expect(splay.isEmpty, isTrue); + }); + + test('combines non-iterable scalars into a list pair', () { + expect(Utils.merge('left', 'right'), equals(['left', 'right'])); + }); + + test('combines scalar and iterable respecting Undefined stripping', () { + final result = Utils.merge( + 'seed', + ['tail', const Undefined()], + ); + expect(result, equals(['seed', 'tail'])); + }); + + test('wraps custom iterables in a list when merging scalar sources', () { + final Iterable iterable = Iterable.generate(1, (i) => 'it-$i'); + + final result = Utils.merge(iterable, 'tail'); + + expect(result, isA()); + final listResult = result as List; + expect(listResult.first, same(iterable)); + expect(listResult.last, equals('tail')); + }); + + test('promotes iterable targets to index maps before merging maps', () { + final result = Utils.merge( + [const Undefined(), 'keep'], + {'extra': 1}, + ) as Map; + + expect(result, equals({'1': 'keep', 'extra': 1})); + }); + + test('wraps scalar targets into heterogeneous lists when merging maps', + () { + final result = Utils.merge( + 'seed', + {'extra': 1}, + ) as List; + + expect(result.first, equals('seed')); + expect(result.last, equals({'extra': 1})); + }); + }); + + group('Utils.encode surrogate handling', () { + const int segmentLimit = 1024; + + String buildBoundaryString() { + final high = String.fromCharCode(0xD83D); + final low = String.fromCharCode(0xDE00); + return '${'a' * (segmentLimit - 1)}$high${low}tail'; + } + + test('avoids splitting surrogate pairs across segments', () { + final encoded = Utils.encode(buildBoundaryString()); + expect(encoded.startsWith('a' * (segmentLimit - 1)), isTrue); + expect(encoded, contains('%F0%9F%98%80')); + expect(encoded.endsWith('tail'), isTrue); + }); + + test('encodes high-and-low surrogate pair to four-byte UTF-8', () { + final emoji = String.fromCharCodes([0xD83D, 0xDE01]); + expect(Utils.encode(emoji), equals('%F0%9F%98%81')); + }); + + test('encodes lone high surrogate as three-byte sequence', () { + final loneHigh = String.fromCharCode(0xD83D); + expect(Utils.encode(loneHigh), equals('%ED%A0%BD')); + }); + + test('encodes lone low surrogate as three-byte sequence', () { + final loneLow = String.fromCharCode(0xDC00); + expect(Utils.encode(loneLow), equals('%ED%B0%80')); + }); + }); + + group('Utils helpers', () { + test('isNonNullishPrimitive treats Uri based on skipNulls flag', () { + final emptyUri = Uri.parse(''); + expect(Utils.isNonNullishPrimitive(emptyUri), isTrue); + expect(Utils.isNonNullishPrimitive(emptyUri, true), isFalse); + final populated = Uri.parse('https://example.com'); + expect(Utils.isNonNullishPrimitive(populated, true), isTrue); + }); + + test('interpretNumericEntities handles astral plane code points', () { + expect(Utils.interpretNumericEntities('😀'), equals('😀')); + }); + + test('createIndexMap materializes non-List iterables', () { + final iterable = Iterable.generate(3, (i) => i * 2); + expect( + Utils.createIndexMap(iterable), + equals({'0': 0, '1': 2, '2': 4}), + ); + }); + }); }); }