Skip to content

Commit 781b305

Browse files
committed
api: Add user_settings/update events, with a test for exhaustiveness
It's been tricky to find a way to verify that the event-handling code keeps up with the settings we add in [UserSettings], the data class we use in the initial snapshot. See #261 for some alternatives we considered. But at least this solution works, with type-checking of the event at the edge, and a mechanism to ensure that all user settings we store in our initial snapshot get updated by the user_settings/update event.
1 parent b1e6021 commit 781b305

File tree

6 files changed

+149
-4
lines changed

6 files changed

+149
-4
lines changed

lib/api/model/events.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:json_annotation/json_annotation.dart';
22

3+
import 'initial_snapshot.dart';
34
import 'model.dart';
45

56
part 'events.g.dart';
@@ -16,6 +17,11 @@ sealed class Event {
1617
factory Event.fromJson(Map<String, dynamic> json) {
1718
switch (json['type'] as String) {
1819
case 'alert_words': return AlertWordsEvent.fromJson(json);
20+
case 'user_settings':
21+
switch (json['op'] as String) {
22+
case 'update': return UserSettingsUpdateEvent.fromJson(json);
23+
default: return UnexpectedEvent.fromJson(json);
24+
}
1925
case 'realm_user':
2026
switch (json['op'] as String) {
2127
case 'add': return RealmUserAddEvent.fromJson(json);
@@ -74,6 +80,53 @@ class AlertWordsEvent extends Event {
7480
Map<String, dynamic> toJson() => _$AlertWordsEventToJson(this);
7581
}
7682

83+
/// A Zulip event of type `user_settings` with op `update`.
84+
@JsonSerializable(fieldRename: FieldRename.snake)
85+
class UserSettingsUpdateEvent extends Event {
86+
@override
87+
@JsonKey(includeToJson: true)
88+
String get type => 'user_settings';
89+
90+
@JsonKey(includeToJson: true)
91+
String get op => 'update';
92+
93+
/// The name of the setting, or null if we don't recognize it.
94+
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
95+
final UserSettingName? property;
96+
97+
/// The new value, or null if we don't recognize the setting.
98+
///
99+
/// This will have the type appropriate for [property]; for example,
100+
/// if the setting is boolean, then `value is bool` will always be true.
101+
/// This invariant is enforced by [UserSettingsUpdateEvent.fromJson].
102+
@JsonKey(readValue: _readValue)
103+
final Object? value;
104+
105+
/// [value], with a check that its type corresponds to [property]
106+
/// (e.g., `value as bool`).
107+
static Object? _readValue(Map json, String key) {
108+
final value = json['value'];
109+
switch (UserSettingName.fromRawString(json['property'] as String)) {
110+
case UserSettingName.displayEmojiReactionUsers:
111+
return value as bool;
112+
case null:
113+
return null;
114+
}
115+
}
116+
117+
UserSettingsUpdateEvent({
118+
required super.id,
119+
required this.property,
120+
required this.value,
121+
});
122+
123+
factory UserSettingsUpdateEvent.fromJson(Map<String, dynamic> json) =>
124+
_$UserSettingsUpdateEventFromJson(json);
125+
126+
@override
127+
Map<String, dynamic> toJson() => _$UserSettingsUpdateEventToJson(this);
128+
}
129+
77130
/// A Zulip event of type `realm_user`.
78131
///
79132
/// The corresponding API docs are in several places for

lib/api/model/events.g.dart

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/api/model/initial_snapshot.dart

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/foundation.dart';
12
import 'package:json_annotation/json_annotation.dart';
23

34
import 'model.dart';
@@ -116,11 +117,14 @@ class RecentDmConversation {
116117
///
117118
/// For docs, search for "user_settings:"
118119
/// in <https://zulip.com/api/register-queue>.
119-
@JsonSerializable(fieldRename: FieldRename.snake)
120+
@JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true)
120121
class UserSettings {
121-
final bool? displayEmojiReactionUsers; // TODO(server-6)
122+
bool? displayEmojiReactionUsers; // TODO(server-6)
122123

123-
// TODO more, as needed
124+
// TODO more, as needed. When adding a setting here, please also:
125+
// (1) add it to the [UserSettingName] enum below
126+
// (2) then re-run the command to refresh the .g.dart files
127+
// (3) handle the event that signals an update to the setting
124128

125129
UserSettings({
126130
required this.displayEmojiReactionUsers,
@@ -130,4 +134,29 @@ class UserSettings {
130134
_$UserSettingsFromJson(json);
131135

132136
Map<String, dynamic> toJson() => _$UserSettingsToJson(this);
137+
138+
/// A list of [UserSettings]'s properties, as strings.
139+
// _$…FieldMap is thanks to `createFieldMap: true`
140+
@visibleForTesting
141+
static final Iterable<String> debugKnownNames = _$UserSettingsFieldMap.keys;
142+
}
143+
144+
/// The name of a user setting that has a property in [UserSettings].
145+
///
146+
/// In Zulip event-handling code (for [UserSettingsUpdateEvent]),
147+
/// we switch exhaustively on a value of this type
148+
/// to ensure that every setting in [UserSettings] responds to the event.
149+
@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true)
150+
enum UserSettingName {
151+
displayEmojiReactionUsers;
152+
153+
/// Get a [UserSettingName] from a raw, snake-case string we recognize, else null.
154+
///
155+
/// Example:
156+
/// 'display_emoji_reaction_users' -> UserSettingName.displayEmojiReactionUsers
157+
static UserSettingName? fromRawString(String raw) => _byRawString[raw];
158+
159+
// _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake`
160+
static final _byRawString = _$UserSettingNameEnumMap
161+
.map((key, value) => MapEntry(value, key));
133162
}

lib/api/model/initial_snapshot.g.dart

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/model/store.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class PerAccountStore extends ChangeNotifier {
174174
final int maxFileUploadSizeMib; // No event for this.
175175

176176
// Data attached to the self-account on the realm.
177-
final UserSettings? userSettings; // TODO(#135) update with user_settings/update event
177+
final UserSettings? userSettings; // TODO(server-5)
178178

179179
// Users and data about them.
180180
final Map<int, User> users;
@@ -219,6 +219,17 @@ class PerAccountStore extends ChangeNotifier {
219219
} else if (event is AlertWordsEvent) {
220220
assert(debugLog("server event: alert_words"));
221221
// We don't yet store this data, so there's nothing to update.
222+
} else if (event is UserSettingsUpdateEvent) {
223+
assert(debugLog("server event: user_settings/update ${event.property?.name ?? '[unrecognized]'}"));
224+
if (event.property == null) {
225+
// unrecognized setting; do nothing
226+
return;
227+
}
228+
switch (event.property!) {
229+
case UserSettingName.displayEmojiReactionUsers:
230+
userSettings?.displayEmojiReactionUsers = event.value as bool;
231+
}
232+
notifyListeners();
222233
} else if (event is RealmUserAddEvent) {
223234
assert(debugLog("server event: realm_user/add"));
224235
users[event.person.userId] = event.person;

test/api/model/events_test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:checks/checks.dart';
22
import 'package:test/scaffolding.dart';
33
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/initial_snapshot.dart';
45

56
import '../../example_data.dart' as eg;
67
import '../../stdlib_checks.dart';
@@ -20,4 +21,24 @@ void main() {
2021
check(mkEvent([])).message.flags.deepEquals([]);
2122
check(mkEvent(['read'])).message.flags.deepEquals(['read']);
2223
});
24+
25+
test('user_settings: all known settings have event handling', () {
26+
final dataClassFieldNames = UserSettings.debugKnownNames;
27+
final enumNames = UserSettingName.values.map((n) => n.name);
28+
final missingEnumNames = dataClassFieldNames.where((key) => !enumNames.contains(key)).toList();
29+
check(
30+
missingEnumNames,
31+
because:
32+
'You have added these fields to [UserSettings]\n'
33+
'without handling the corresponding forms of the\n'
34+
'user_settings/update event in [PerAccountStore]:\n'
35+
' $missingEnumNames\n'
36+
'To do that, please follow these steps:\n'
37+
' (1) Add corresponding members to the [UserSettingName] enum.\n'
38+
' (2) Then, re-run the command to refresh the .g.dart files.\n'
39+
' (3) Resolve the Dart analysis errors about not exhaustively\n'
40+
' matching on that enum, by adding new `switch` cases\n'
41+
' on the pattern of the existing cases.'
42+
).isEmpty();
43+
});
2344
}

0 commit comments

Comments
 (0)