Skip to content

Commit f662a01

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. It's possible we'll find a better solution. In particular, the subclasses of [UserSettingsUpdateEvent] seem verbose, in both their names and their implementations. That's: - UserSettingsUpdateEventDisplayEmojiReactionUsers - UserSettingsUpdateEventUnknown and presumably later: - UserSettingsUpdateEventEnableStreamPushNotifications - UserSettingsUpdateEventEnableFollowedTopicPushNotifications - (and so on.) 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 e5aca6a commit f662a01

File tree

6 files changed

+165
-4
lines changed

6 files changed

+165
-4
lines changed

lib/api/model/events.dart

Lines changed: 58 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,12 @@ 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':
23+
return UserSettingsUpdateEvent.fromJson(json);
24+
default: return UnexpectedEvent.fromJson(json);
25+
}
1926
case 'realm_user':
2027
switch (json['op'] as String) {
2128
case 'add': return RealmUserAddEvent.fromJson(json);
@@ -74,6 +81,57 @@ class AlertWordsEvent extends Event {
7481
Map<String, dynamic> toJson() => _$AlertWordsEventToJson(this);
7582
}
7683

84+
/// A Zulip event of type `user_settings` with op `update`.
85+
sealed class UserSettingsUpdateEvent extends Event {
86+
@override
87+
@JsonKey(includeToJson: true)
88+
String get type => 'user_settings';
89+
90+
String get op => 'update';
91+
92+
UserSettingsUpdateEvent({
93+
required super.id,
94+
});
95+
96+
factory UserSettingsUpdateEvent.fromJson(Map<String, dynamic> json) {
97+
switch (UserSettingName.fromRawString(json['property'] as String)) {
98+
case UserSettingName.displayEmojiReactionUsers:
99+
return UserSettingsUpdateEventDisplayEmojiReactionUsers.fromJson(json);
100+
case null:
101+
return UserSettingsUpdateEventUnknown.fromJson(json);
102+
}
103+
}
104+
}
105+
106+
@JsonSerializable(fieldRename: FieldRename.snake)
107+
class UserSettingsUpdateEventDisplayEmojiReactionUsers extends UserSettingsUpdateEvent {
108+
final bool value;
109+
110+
UserSettingsUpdateEventDisplayEmojiReactionUsers({
111+
required super.id,
112+
required this.value,
113+
});
114+
115+
factory UserSettingsUpdateEventDisplayEmojiReactionUsers.fromJson(Map<String, dynamic> json) =>
116+
_$UserSettingsUpdateEventDisplayEmojiReactionUsersFromJson(json);
117+
118+
@override
119+
Map<String, dynamic> toJson() => _$UserSettingsUpdateEventDisplayEmojiReactionUsersToJson(this);
120+
}
121+
122+
@JsonSerializable(fieldRename: FieldRename.snake)
123+
class UserSettingsUpdateEventUnknown extends UserSettingsUpdateEvent {
124+
UserSettingsUpdateEventUnknown({
125+
required super.id,
126+
});
127+
128+
factory UserSettingsUpdateEventUnknown.fromJson(Map<String, dynamic> json) =>
129+
_$UserSettingsUpdateEventUnknownFromJson(json);
130+
131+
@override
132+
Map<String, dynamic> toJson() => _$UserSettingsUpdateEventUnknownToJson(this);
133+
}
134+
77135
/// A Zulip event of type `realm_user`.
78136
///
79137
/// The corresponding API docs are in several places for

lib/api/model/events.g.dart

Lines changed: 29 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: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/cupertino.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) write a new [UserSettingsUpdateEvent] subclass for it
127+
// (3) handle that new subclass in [PerAccountStore]
124128

125129
UserSettings({
126130
this.displayEmojiReactionUsers,
@@ -131,3 +135,34 @@ class UserSettings {
131135

132136
Map<String, dynamic> toJson() => _$UserSettingsToJson(this);
133137
}
138+
139+
/// The name of a user setting that has a property in [UserSettings].
140+
///
141+
/// In Zulip event-handling code (for [UserSettingsUpdateEvent]),
142+
/// we switch exhaustively on a value of this type
143+
/// to ensure that every setting in [UserSettings] responds to the event.
144+
@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true)
145+
enum UserSettingName {
146+
displayEmojiReactionUsers;
147+
148+
/// Get a [UserSettingName] from a raw, snake-case string we recognize, else null.
149+
///
150+
/// Example:
151+
/// 'display_emoji_reaction_users' -> UserSettingName.displayEmojiReactionUsers
152+
static UserSettingName? fromRawString(String raw) => _byRawString[raw];
153+
154+
static final _byRawString = Map.fromEntries(
155+
_$UserSettingNameEnumMap // thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake`
156+
.entries.map((entry) => MapEntry(entry.value, entry.key)),
157+
);
158+
159+
/// A list of [UserSettings] properties not accounted for in this enum.
160+
///
161+
/// For test code to check that all of those properties are accounted for.
162+
@visibleForTesting
163+
static List<String> missingNames() {
164+
final enumNames = values.map((n) => n.name);
165+
final dataClassFieldNames = _$UserSettingsFieldMap.keys; // thanks to `createFieldMap: true`
166+
return dataClassFieldNames.where((key) => !enumNames.contains(key)).toList();
167+
}
168+
}

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: 9 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;
178178

179179
// Users and data about them.
180180
final Map<int, User> users;
@@ -219,6 +219,14 @@ 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"));
224+
switch (event) {
225+
case UserSettingsUpdateEventDisplayEmojiReactionUsers():
226+
userSettings!.displayEmojiReactionUsers = event.value;
227+
case UserSettingsUpdateEventUnknown():
228+
// do nothing
229+
}
222230
} else if (event is RealmUserAddEvent) {
223231
assert(debugLog("server event: realm_user/add"));
224232
users[event.person.userId] = event.person;

test/api/model/events_test.dart

Lines changed: 23 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 'events_checks.dart';
@@ -19,4 +20,26 @@ void main() {
1920
check(mkEvent([])).message.flags.deepEquals([]);
2021
check(mkEvent(['read'])).message.flags.deepEquals(['read']);
2122
});
23+
24+
test('user_settings: all known settings have event handling', () {
25+
final missingEnumNames = UserSettingName.missingNames();
26+
check(
27+
missingEnumNames,
28+
because:
29+
'You have added these fields to [UserSettings]\n'
30+
'without handling the corresponding forms of the\n'
31+
'user_settings/update event in [PerAccountStore]:\n'
32+
' $missingEnumNames\n'
33+
'To do that, please follow these steps:\n'
34+
' (1) Add corresponding members to the [UserSettingName] enum.\n'
35+
' (2) Then, when you see a Dart analysis error about\n'
36+
' not exhaustively matching on `UserSettingName?`,\n'
37+
' where we represent user_settings/update events,\n'
38+
' please handle it by adding a new case\n'
39+
' on the pattern of the other cases you see there.\n'
40+
' (3) Then, when you see another Dart analysis error about\n'
41+
' not handling the new event type in [PerAccountStore],\n'
42+
' please do so, on the pattern of the other cases there.'
43+
).isEmpty();
44+
});
2245
}

0 commit comments

Comments
 (0)