Skip to content

Commit 16267c5

Browse files
committed
✅ add regression coverage for encoder refactor
Add focused tests for filter-wrapped cycle detection, KeyPathNode caching/materialization edge cases, and EncodeConfig copyWith sentinel semantics.
1 parent 1bdb0b4 commit 16267c5

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

test/unit/encode_edge_cases_test.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,37 @@ void main() {
8686
expect(encoded.contains('b%5Bz%5D=1'), isTrue);
8787
});
8888

89+
test(
90+
'cycle detection still throws when filter wraps maps with fresh containers',
91+
() {
92+
final cyclic = <String, dynamic>{};
93+
cyclic['self'] = cyclic;
94+
95+
var wraps = 0;
96+
final options = EncodeOptions(
97+
encode: false,
98+
filter: (prefix, value) {
99+
if (value is Map) {
100+
wraps++;
101+
if (wraps > 8) {
102+
// Keep this bounded so a regression fails quickly instead of
103+
// risking unbounded expansion during traversal.
104+
return 'stop';
105+
}
106+
107+
return {'wrapped': value};
108+
}
109+
110+
return value;
111+
},
112+
);
113+
114+
expect(
115+
() => QS.encode({'root': cyclic}, options),
116+
throwsA(isA<RangeError>()),
117+
);
118+
});
119+
89120
test('strictNullHandling with custom encoder emits only encoded key', () {
90121
final encoded = QS.encode(
91122
{
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import 'dart:convert' show Encoding, utf8;
2+
3+
import 'package:qs_dart/src/enums/format.dart';
4+
import 'package:qs_dart/src/enums/list_format.dart';
5+
import 'package:qs_dart/src/models/encode_config.dart';
6+
import 'package:qs_dart/src/models/encode_options.dart';
7+
import 'package:test/test.dart';
8+
9+
String _encoder(dynamic value, {Encoding? charset, Format? format}) =>
10+
value.toString();
11+
String _serializeDate(DateTime date) => date.toIso8601String();
12+
int _sort(dynamic a, dynamic b) => 0;
13+
14+
void main() {
15+
group('EncodeConfig', () {
16+
test('copyWith returns same instance when unchanged', () {
17+
final config = _baseConfig();
18+
19+
expect(identical(config.copyWith(), config), isTrue);
20+
});
21+
22+
test('withEncoder delegates to copyWith and can clear encoder', () {
23+
final config = _baseConfig(encoder: _encoder);
24+
25+
final cleared = config.withEncoder(null);
26+
27+
expect(cleared, equals(config.copyWith(encoder: null)));
28+
expect(cleared.encoder, isNull);
29+
expect(identical(cleared, config), isFalse);
30+
});
31+
32+
test('copyWith can clear nullable fields', () {
33+
final config = _baseConfig(
34+
encoder: _encoder,
35+
serializeDate: _serializeDate,
36+
sort: _sort,
37+
filter: const ['a', 'b'],
38+
);
39+
40+
final copy = config.copyWith(
41+
encoder: null,
42+
serializeDate: null,
43+
sort: null,
44+
filter: null,
45+
);
46+
47+
expect(copy.encoder, isNull);
48+
expect(copy.serializeDate, isNull);
49+
expect(copy.sort, isNull);
50+
expect(copy.filter, isNull);
51+
expect(copy.generateArrayPrefix, same(config.generateArrayPrefix));
52+
expect(copy.formatter, same(config.formatter));
53+
});
54+
55+
test('copyWith treats const Object filter as explicit override', () {
56+
const marker = Object();
57+
final config = _baseConfig(filter: null);
58+
59+
final copy = config.copyWith(filter: marker);
60+
61+
expect(identical(copy.filter, marker), isTrue);
62+
});
63+
64+
test('equatable props compare all fields', () {
65+
final a = _baseConfig();
66+
final b = _baseConfig();
67+
68+
expect(a, equals(b));
69+
expect(a.copyWith(skipNulls: true), isNot(equals(b)));
70+
});
71+
});
72+
}
73+
74+
EncodeConfig _baseConfig({
75+
ListFormatGenerator? generateArrayPrefix,
76+
bool commaRoundTrip = false,
77+
bool commaCompactNulls = false,
78+
bool allowEmptyLists = false,
79+
bool strictNullHandling = false,
80+
bool skipNulls = false,
81+
bool encodeDotInKeys = false,
82+
Encoder? encoder,
83+
DateSerializer? serializeDate,
84+
Sorter? sort,
85+
dynamic filter,
86+
bool allowDots = false,
87+
Format format = Format.rfc3986,
88+
Formatter? formatter,
89+
bool encodeValuesOnly = false,
90+
}) {
91+
return EncodeConfig(
92+
generateArrayPrefix: generateArrayPrefix ?? ListFormat.indices.generator,
93+
commaRoundTrip: commaRoundTrip,
94+
commaCompactNulls: commaCompactNulls,
95+
allowEmptyLists: allowEmptyLists,
96+
strictNullHandling: strictNullHandling,
97+
skipNulls: skipNulls,
98+
encodeDotInKeys: encodeDotInKeys,
99+
encoder: encoder,
100+
serializeDate: serializeDate,
101+
sort: sort,
102+
filter: filter,
103+
allowDots: allowDots,
104+
format: format,
105+
formatter: formatter ?? format.formatter,
106+
encodeValuesOnly: encodeValuesOnly,
107+
charset: utf8,
108+
);
109+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import 'package:qs_dart/src/models/key_path_node.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
group('KeyPathNode', () {
6+
test('append returns same node for empty segment', () {
7+
final root = KeyPathNode.fromMaterialized('a');
8+
9+
expect(identical(root.append(''), root), isTrue);
10+
});
11+
12+
test('materialize composes full path once', () {
13+
final node =
14+
KeyPathNode.fromMaterialized('a').append('[b]').append('[c]');
15+
16+
final first = node.materialize();
17+
final second = node.materialize();
18+
19+
expect(first, equals('a[b][c]'));
20+
expect(identical(first, second), isTrue);
21+
});
22+
23+
test('materialize composes from nearest cached ancestor', () {
24+
final parent =
25+
KeyPathNode.fromMaterialized('a').append('[b]').append('[c]');
26+
final leaf = parent.append('[d]');
27+
28+
expect(parent.materialize(), equals('a[b][c]'));
29+
final first = leaf.materialize();
30+
final second = leaf.materialize();
31+
32+
expect(first, equals('a[b][c][d]'));
33+
expect(identical(first, second), isTrue);
34+
});
35+
36+
test('asDotEncoded returns same node when there are no dots', () {
37+
final node = KeyPathNode.fromMaterialized('a').append('[b]');
38+
39+
final encoded = node.asDotEncoded();
40+
41+
expect(identical(encoded, node), isTrue);
42+
});
43+
44+
test('asDotEncoded encodes dots in a root node', () {
45+
final root = KeyPathNode.fromMaterialized('a.b.c');
46+
47+
expect(root.asDotEncoded().materialize(), equals('a%2Eb%2Ec'));
48+
});
49+
50+
test('asDotEncoded caches encoded view across calls', () {
51+
final node =
52+
KeyPathNode.fromMaterialized('a.b').append('[c.d]').append('[e]');
53+
54+
final first = node.asDotEncoded();
55+
final second = node.asDotEncoded();
56+
57+
expect(first.materialize(), equals('a%2Eb[c%2Ed][e]'));
58+
expect(identical(first, second), isTrue);
59+
});
60+
61+
test('asDotEncoded handles deep uncached chains without recursion', () {
62+
KeyPathNode node = KeyPathNode.fromMaterialized('root.part');
63+
for (int i = 0; i < 5000; i++) {
64+
node = node.append('[k$i.v$i]');
65+
}
66+
67+
final encodedFirst = node.asDotEncoded();
68+
final encodedSecond = node.asDotEncoded();
69+
70+
expect(identical(encodedFirst, encodedSecond), isTrue);
71+
expect(encodedFirst.materialize().startsWith('root%2Epart[k0%2Ev0]'),
72+
isTrue);
73+
expect(encodedFirst.materialize().contains('%2E'), isTrue);
74+
});
75+
76+
test('deep chain materialize and asDotEncoded use cached results', () {
77+
final node = KeyPathNode.fromMaterialized('a.b')
78+
.append('[c.d]')
79+
.append('[e.f]')
80+
.append('[g.h]')
81+
.append('[i]');
82+
83+
final materializedFirst = node.materialize();
84+
final materializedSecond = node.materialize();
85+
86+
expect(materializedFirst, equals('a.b[c.d][e.f][g.h][i]'));
87+
expect(identical(materializedFirst, materializedSecond), isTrue);
88+
89+
final encodedFirst = node.asDotEncoded();
90+
final encodedSecond = node.asDotEncoded();
91+
92+
expect(
93+
encodedFirst.materialize(), equals('a%2Eb[c%2Ed][e%2Ef][g%2Eh][i]'));
94+
expect(identical(encodedFirst, encodedSecond), isTrue);
95+
});
96+
});
97+
}

0 commit comments

Comments
 (0)