Skip to content

Commit d266a1b

Browse files
dougborgclaude
authored andcommitted
Add state management integration tests
13 tests verifying the coordination between SessionState and UploadQueueState that AppState.commitSession() performs: - Full add flow: startAddSession -> set target + profile -> commitSession -> addFromSession -> queue has 1 item, session null - Full edit flow: both modify and extract paths - Commit guards: incomplete session doesn't add to queue, double commit is safe (second returns null) - Profile deletion callback: deleting profile used in active add/edit session cancels that session; unrelated profile deletion doesn't affect session - Notification propagation: sub-module notifyListeners fires on all state-changing operations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent df8fda1 commit d266a1b

1 file changed

Lines changed: 388 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
import 'package:flutter/widgets.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:latlong2/latlong.dart';
4+
import 'package:mocktail/mocktail.dart';
5+
import 'package:shared_preferences/shared_preferences.dart';
6+
7+
import 'package:deflockapp/state/upload_queue_state.dart';
8+
import 'package:deflockapp/state/session_state.dart';
9+
import 'package:deflockapp/state/settings_state.dart';
10+
import 'package:deflockapp/models/node_profile.dart';
11+
import 'package:deflockapp/models/operator_profile.dart';
12+
import 'package:deflockapp/models/osm_node.dart';
13+
import 'package:deflockapp/models/pending_upload.dart';
14+
import 'package:deflockapp/services/map_data_provider.dart';
15+
import 'package:deflockapp/widgets/node_provider_with_cache.dart';
16+
17+
// ---------------------------------------------------------------------------
18+
// Mocks
19+
// ---------------------------------------------------------------------------
20+
21+
class MockMapDataProvider extends Mock implements MapDataProvider {}
22+
23+
class MockNodeProviderWithCache extends Mock implements NodeProviderWithCache {}
24+
25+
// ---------------------------------------------------------------------------
26+
// Helpers
27+
// ---------------------------------------------------------------------------
28+
29+
NodeProfile _flockProfile() => NodeProfile(
30+
id: 'flock',
31+
name: 'Flock',
32+
tags: const {
33+
'man_made': 'surveillance',
34+
'surveillance': 'public',
35+
'surveillance:type': 'ALPR',
36+
'camera:mount': '',
37+
'manufacturer': 'Flock Safety',
38+
},
39+
submittable: true,
40+
requiresDirection: true,
41+
);
42+
43+
NodeProfile _motorolaProfile() => NodeProfile(
44+
id: 'motorola',
45+
name: 'Motorola',
46+
tags: const {
47+
'man_made': 'surveillance',
48+
'surveillance': 'public',
49+
'surveillance:type': 'ALPR',
50+
'manufacturer': 'Motorola Solutions',
51+
},
52+
submittable: true,
53+
requiresDirection: true,
54+
);
55+
56+
OperatorProfile _operatorProfile() => OperatorProfile(
57+
id: 'lowes',
58+
name: "Lowe's",
59+
tags: const {'operator': "Lowe's"},
60+
);
61+
62+
OsmNode _testNode() => OsmNode(
63+
id: 42,
64+
coord: const LatLng(40.0, -75.0),
65+
tags: const {
66+
'man_made': 'surveillance',
67+
'surveillance': 'public',
68+
'surveillance:type': 'ALPR',
69+
'manufacturer': 'Flock Safety',
70+
'direction': '90',
71+
'operator': "Lowe's",
72+
},
73+
);
74+
75+
OsmNode _constrainedNode() => OsmNode(
76+
id: 44,
77+
coord: const LatLng(40.0, -75.0),
78+
tags: const {
79+
'man_made': 'surveillance',
80+
'surveillance:type': 'ALPR',
81+
'direction': '180',
82+
},
83+
isConstrained: true,
84+
);
85+
86+
List<NodeProfile> _enabledProfiles() => [_flockProfile(), _motorolaProfile()];
87+
List<OperatorProfile> _operatorProfiles() => [_operatorProfile()];
88+
89+
/// Create a pair of (SessionState, UploadQueueState) with mock cache.
90+
({SessionState session, UploadQueueState queue}) _createModules() {
91+
final mockCache = MockMapDataProvider();
92+
final mockProvider = MockNodeProviderWithCache();
93+
// Void methods are auto-stubbed by mocktail — no explicit stubs needed.
94+
95+
return (
96+
session: SessionState(),
97+
queue: UploadQueueState(nodeCache: mockCache, nodeProvider: mockProvider),
98+
);
99+
}
100+
101+
// ---------------------------------------------------------------------------
102+
// Tests -- these replicate the method sequences AppState.commitSession() etc.
103+
// execute, without needing the full AppState (which triggers heavy async init).
104+
// ---------------------------------------------------------------------------
105+
106+
void main() {
107+
TestWidgetsFlutterBinding.ensureInitialized();
108+
109+
setUp(() {
110+
SharedPreferences.setMockInitialValues({});
111+
});
112+
113+
// =========================================================================
114+
// Full add flow
115+
// =========================================================================
116+
group('Full add flow', () {
117+
test('startAddSession -> set target + profile -> commitSession -> addFromSession', () {
118+
final m = _createModules();
119+
final s = m.session;
120+
final q = m.queue;
121+
122+
// 1. Start session
123+
s.startAddSession(_enabledProfiles());
124+
expect(s.session, isNotNull);
125+
126+
// 2. Set target and profile
127+
s.updateSession(
128+
target: const LatLng(40.0, -75.0),
129+
profile: _flockProfile(),
130+
);
131+
132+
// 3. Commit session (like AppState.commitSession)
133+
final committed = s.commitSession();
134+
expect(committed, isNotNull);
135+
expect(s.session, isNull);
136+
137+
// 4. Add to queue (like AppState.commitSession does)
138+
q.addFromSession(committed!, uploadMode: UploadMode.simulate);
139+
expect(q.pendingCount, equals(1));
140+
expect(q.pendingUploads.first.operation, equals(UploadOperation.create));
141+
});
142+
});
143+
144+
// =========================================================================
145+
// Full edit flow
146+
// =========================================================================
147+
group('Full edit flow', () {
148+
test('modify path: startEditSession -> update profile -> commitEditSession -> addFromEditSession', () {
149+
final m = _createModules();
150+
final s = m.session;
151+
final q = m.queue;
152+
153+
// 1. Start edit session from existing node
154+
s.startEditSession(_testNode(), _enabledProfiles(), _operatorProfiles());
155+
expect(s.editSession, isNotNull);
156+
157+
// 2. Change profile
158+
s.updateEditSession(profile: _flockProfile());
159+
160+
// 3. Commit
161+
final committed = s.commitEditSession();
162+
expect(committed, isNotNull);
163+
expect(s.editSession, isNull);
164+
165+
// 4. Add to queue
166+
q.addFromEditSession(committed!, uploadMode: UploadMode.simulate);
167+
expect(q.pendingCount, equals(1));
168+
expect(q.pendingUploads.first.operation, equals(UploadOperation.modify));
169+
expect(q.pendingUploads.first.originalNodeId, equals(42));
170+
});
171+
172+
test('extract path: constrained node -> extractFromWay -> commit -> addFromEditSession', () {
173+
final m = _createModules();
174+
final s = m.session;
175+
final q = m.queue;
176+
177+
// 1. Start edit session from constrained node
178+
s.startEditSession(_constrainedNode(), _enabledProfiles(), _operatorProfiles());
179+
180+
// 2. Enable extract and move target
181+
s.updateEditSession(
182+
extractFromWay: true,
183+
target: const LatLng(41.0, -74.0),
184+
profile: _flockProfile(),
185+
);
186+
187+
// 3. Commit
188+
final committed = s.commitEditSession();
189+
expect(committed, isNotNull);
190+
191+
// 4. Add to queue
192+
q.addFromEditSession(committed!, uploadMode: UploadMode.simulate);
193+
expect(q.pendingCount, equals(1));
194+
expect(q.pendingUploads.first.operation, equals(UploadOperation.extract));
195+
});
196+
});
197+
198+
// =========================================================================
199+
// Commit guards
200+
// =========================================================================
201+
group('Commit guards', () {
202+
test('incomplete session does not add to queue', () {
203+
final m = _createModules();
204+
final s = m.session;
205+
final q = m.queue;
206+
207+
s.startAddSession(_enabledProfiles());
208+
// Only set profile, no target
209+
s.updateSession(profile: _flockProfile());
210+
211+
final committed = s.commitSession();
212+
expect(committed, isNull);
213+
214+
// Queue should remain empty
215+
expect(q.pendingCount, equals(0));
216+
});
217+
218+
test('double commit is safe: second returns null and queue has only 1 item', () {
219+
final m = _createModules();
220+
final s = m.session;
221+
final q = m.queue;
222+
223+
s.startAddSession(_enabledProfiles());
224+
s.updateSession(
225+
target: const LatLng(40.0, -75.0),
226+
profile: _flockProfile(),
227+
);
228+
229+
// First commit succeeds
230+
final first = s.commitSession();
231+
expect(first, isNotNull);
232+
q.addFromSession(first!, uploadMode: UploadMode.simulate);
233+
234+
// Second commit returns null
235+
final second = s.commitSession();
236+
expect(second, isNull);
237+
238+
// Queue should have exactly 1 item
239+
expect(q.pendingCount, equals(1));
240+
});
241+
242+
test('double edit commit is safe', () {
243+
final m = _createModules();
244+
final s = m.session;
245+
final q = m.queue;
246+
247+
s.startEditSession(_testNode(), _enabledProfiles(), _operatorProfiles());
248+
249+
final first = s.commitEditSession();
250+
expect(first, isNotNull);
251+
q.addFromEditSession(first!, uploadMode: UploadMode.simulate);
252+
253+
final second = s.commitEditSession();
254+
expect(second, isNull);
255+
expect(q.pendingCount, equals(1));
256+
});
257+
});
258+
259+
// =========================================================================
260+
// Profile deletion callback
261+
// =========================================================================
262+
group('Profile deletion callback', () {
263+
test('deleting profile used in active add session cancels that session', () {
264+
final m = _createModules();
265+
final s = m.session;
266+
267+
s.startAddSession(_enabledProfiles());
268+
s.updateSession(profile: _flockProfile());
269+
expect(s.session?.profile?.id, equals('flock'));
270+
271+
// Simulate what AppState._onProfileDeleted does
272+
if (s.session?.profile?.id == 'flock') {
273+
s.cancelSession();
274+
}
275+
276+
expect(s.session, isNull);
277+
});
278+
279+
test('deleting profile used in active edit session cancels that session', () {
280+
final m = _createModules();
281+
final s = m.session;
282+
283+
s.startEditSession(_testNode(), _enabledProfiles(), _operatorProfiles());
284+
s.updateEditSession(profile: _flockProfile());
285+
expect(s.editSession?.profile?.id, equals('flock'));
286+
287+
// Simulate what AppState._onProfileDeleted does
288+
if (s.editSession?.profile?.id == 'flock') {
289+
s.cancelEditSession();
290+
}
291+
292+
expect(s.editSession, isNull);
293+
});
294+
295+
test('deleting unrelated profile does not affect session', () {
296+
final m = _createModules();
297+
final s = m.session;
298+
299+
s.startAddSession(_enabledProfiles());
300+
s.updateSession(profile: _flockProfile());
301+
302+
// Simulate deleting a different profile
303+
final deletedProfile = _motorolaProfile();
304+
if (s.session?.profile?.id == deletedProfile.id) {
305+
s.cancelSession();
306+
}
307+
308+
// Session should still be active with flock profile
309+
expect(s.session, isNotNull);
310+
expect(s.session!.profile!.id, equals('flock'));
311+
});
312+
313+
test('deleting unrelated profile does not affect edit session', () {
314+
final m = _createModules();
315+
final s = m.session;
316+
317+
s.startEditSession(_testNode(), _enabledProfiles(), _operatorProfiles());
318+
s.updateEditSession(profile: _flockProfile());
319+
320+
final deletedProfile = _motorolaProfile();
321+
if (s.editSession?.profile?.id == deletedProfile.id) {
322+
s.cancelEditSession();
323+
}
324+
325+
expect(s.editSession, isNotNull);
326+
expect(s.editSession!.profile!.id, equals('flock'));
327+
});
328+
});
329+
330+
// =========================================================================
331+
// Notification propagation
332+
// =========================================================================
333+
group('Notification propagation', () {
334+
test('SessionState notifyListeners fires on add session operations', () {
335+
final s = SessionState();
336+
int count = 0;
337+
s.addListener(() => count++);
338+
339+
s.startAddSession(_enabledProfiles());
340+
expect(count, equals(1));
341+
342+
s.updateSession(target: const LatLng(40.0, -75.0));
343+
expect(count, equals(2));
344+
345+
s.updateSession(profile: _flockProfile());
346+
expect(count, equals(3));
347+
348+
s.commitSession();
349+
expect(count, equals(4));
350+
});
351+
352+
test('SessionState notifyListeners fires on edit session operations', () {
353+
final s = SessionState();
354+
int count = 0;
355+
s.addListener(() => count++);
356+
357+
s.startEditSession(_testNode(), _enabledProfiles(), _operatorProfiles());
358+
expect(count, equals(1));
359+
360+
s.updateEditSession(profile: _flockProfile());
361+
expect(count, equals(2));
362+
363+
s.commitEditSession();
364+
expect(count, equals(3));
365+
});
366+
367+
test('UploadQueueState notifyListeners fires on queue operations', () {
368+
final m = _createModules();
369+
final q = m.queue;
370+
int count = 0;
371+
q.addListener(() => count++);
372+
373+
final session = m.session;
374+
session.startAddSession(_enabledProfiles());
375+
session.updateSession(
376+
target: const LatLng(40.0, -75.0),
377+
profile: _flockProfile(),
378+
);
379+
final committed = session.commitSession();
380+
381+
q.addFromSession(committed!, uploadMode: UploadMode.simulate);
382+
expect(count, equals(1));
383+
384+
q.clearQueue();
385+
expect(count, equals(2));
386+
});
387+
});
388+
}

0 commit comments

Comments
 (0)