feat(shorebird_code_push_protocol): unique-users current/previous envelope + shared MetricsRange [1/5] (#3821)

This commit is contained in:
Mac
2026-06-16 17:45:19 -06:00
committed by GitHub
parent 8f2efcfada
commit 6fc2d8dfa3
19 changed files with 474 additions and 172 deletions
@@ -52,6 +52,7 @@ export 'package:shorebird_code_push_protocol/src/models/get_patch_adoption_param
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
export 'package:shorebird_code_push_protocol/src/models/metrics_range.dart';
export 'package:shorebird_code_push_protocol/src/models/organization.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_membership.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_type.dart';
@@ -59,7 +60,6 @@ export 'package:shorebird_code_push_protocol/src/models/organization_user.dart';
export 'package:shorebird_code_push_protocol/src/models/patch.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_point.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_range.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_artifact.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_check_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/pending_release.dart';
@@ -73,8 +73,9 @@ export 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
export 'package:shorebird_code_push_protocol/src/models/release_status.dart';
export 'package:shorebird_code_push_protocol/src/models/role.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_breakdown_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_range.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_current_window.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_window.dart';
export 'package:shorebird_code_push_protocol/src/models/version_distribution_entry.dart';
/// Parsed JSON data.
@@ -1,7 +1,7 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_adoption_range.dart';
/// {@template get_patch_adoption_response}
/// The response body for GET /apps/{appId}/metrics/patch-adoption. Covers
@@ -28,9 +28,7 @@ class GetPatchAdoptionResponse {
releaseVersion: json['release_version'] as String,
isLatest: json['is_latest'] as bool,
granularity: checkedKey(json, 'granularity') as String?,
range: PatchAdoptionRange.fromJson(
json['range'] as Map<String, dynamic>,
),
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
asOf: DateTime.parse(json['as_of'] as String),
patches: (json['patches'] as List)
.map<PatchAdoptionEntry>(
@@ -57,12 +55,15 @@ class GetPatchAdoptionResponse {
/// creation date" default (no `release_version` was supplied).
final bool isLatest;
/// The bucket resolution (`hour`, `day`, `week`, or `month`), or null
/// The bucket resolution (`hour`, `day`, or `week`), or null
/// when each patch carries a single full-window value.
final String? granularity;
/// The effective (post-clamp) window the response covers.
final PatchAdoptionRange range;
/// The effective (post-default, post-clamp) window a metrics response
/// or one window of a metrics envelope — covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
final MetricsRange range;
/// Server's UTC timestamp at the moment the response was
/// constructed. Not a freshness indicator for the underlying
@@ -1,22 +1,27 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_breakdown_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_range.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_current_window.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_window.dart';
/// {@template get_unique_users_response}
/// The response body for GET /apps/{appId}/metrics/unique-users.
/// The response body for GET /apps/{appId}/metrics/unique-users: a
/// current/previous envelope. `previous` covers the equal-length window
/// immediately preceding `current`; period-over-period deltas are client
/// display logic over the two totals. `previous` is always present but is
/// null when the prior window predates the data floor (no comparison data
/// exists). When it reaches past the plan's metrics-history horizon,
/// `previous` is non-null with its total — the delta renders — but without
/// a `time_series` (no prior-window overlay): granular history is the
/// resolution the horizon gates, the scalar comparison is not.
/// {@endtemplate}
@immutable
class GetUniqueUsersResponse {
/// {@macro get_unique_users_response}
const GetUniqueUsersResponse({
required this.uniqueUsers,
required this.granularity,
required this.range,
required this.asOf,
this.timeSeries,
this.breakdown,
required this.granularity,
required this.current,
required this.previous,
});
/// Converts a `Map<String, dynamic>` to a [GetUniqueUsersResponse].
@@ -25,23 +30,14 @@ class GetUniqueUsersResponse {
'GetUniqueUsersResponse',
json,
() => GetUniqueUsersResponse(
uniqueUsers: json['unique_users'] as int,
granularity: checkedKey(json, 'granularity') as String?,
range: UniqueUsersRange.fromJson(json['range'] as Map<String, dynamic>),
asOf: DateTime.parse(json['as_of'] as String),
timeSeries: (json['time_series'] as List?)
?.map<UniqueUsersTimeSeriesEntry>(
(e) => UniqueUsersTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
breakdown: (json['breakdown'] as List?)
?.map<UniqueUsersBreakdownEntry>(
(e) =>
UniqueUsersBreakdownEntry.fromJson(e as Map<String, dynamic>),
)
.toList(),
granularity: checkedKey(json, 'granularity') as String?,
current: UniqueUsersCurrentWindow.fromJson(
json['current'] as Map<String, dynamic>,
),
previous: UniqueUsersWindow.maybeFromJson(
checkedKey(json, 'previous') as Map<String, dynamic>?,
),
),
);
}
@@ -55,60 +51,53 @@ class GetUniqueUsersResponse {
return GetUniqueUsersResponse.fromJson(json);
}
/// Distinct active devices over the whole window (an HLL count).
final int uniqueUsers;
/// The time-series bucket resolution (`hour`, `day`, `week`, or
/// `month`), or null when no time series was requested.
final String? granularity;
/// The window the unique-users response covers.
final UniqueUsersRange range;
/// Server's UTC timestamp at the moment the response was constructed.
/// Not a freshness indicator for the underlying data, which is
/// refreshed by an hourly scheduled query and may lag by up to ~1 hour.
final DateTime asOf;
/// The top-level time series, present only when a `granularity` was
/// requested; otherwise null.
final List<UniqueUsersTimeSeriesEntry>? timeSeries;
/// The time-series bucket resolution (`hour`, `day`, or `week`), or
/// null when no time series was requested. Applies to both windows.
final String? granularity;
/// Per-group unique users, present only when a `group_by` was
/// requested; otherwise null.
final List<UniqueUsersBreakdownEntry>? breakdown;
/// The `current` window of the unique-users envelope: the base window
/// atom plus the optional `breakdown`. Only `current` carries a
/// breakdown — no chart renders a previous-window breakdown, so the
/// asymmetry is declared in the contract rather than left as an
/// optional-but-never-populated field.
final UniqueUsersCurrentWindow current;
/// One window of the unique-users envelope: the HLL-merged total over
/// the window's effective range, with a per-bucket series when a
/// `granularity` was requested. This base atom is the full shape of
/// `previous`; `current` extends it (see UniqueUsersCurrentWindow).
final UniqueUsersWindow? previous;
/// Converts a [GetUniqueUsersResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'unique_users': uniqueUsers,
'granularity': granularity,
'range': range.toJson(),
'as_of': asOf.toIso8601String(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
'breakdown': breakdown?.map((e) => e.toJson()).toList(),
'granularity': granularity,
'current': current.toJson(),
'previous': previous?.toJson(),
};
}
@override
int get hashCode => Object.hashAll([
uniqueUsers,
granularity,
range,
asOf,
listHash(timeSeries),
listHash(breakdown),
granularity,
current,
previous,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetUniqueUsersResponse &&
uniqueUsers == other.uniqueUsers &&
granularity == other.granularity &&
range == other.range &&
asOf == other.asOf &&
listsEqual(timeSeries, other.timeSeries) &&
listsEqual(breakdown, other.breakdown);
granularity == other.granularity &&
current == other.current &&
previous == other.previous;
}
}
@@ -1,8 +1,7 @@
enum GetPatchAdoptionParameter3 {
hour._('hour'),
day._('day'),
week._('week'),
month._('month');
week._('week');
const GetPatchAdoptionParameter3._(this.value);
@@ -1,8 +1,7 @@
enum GetUniqueUsersParameter2 {
hour._('hour'),
day._('day'),
week._('week'),
month._('month');
week._('week');
const GetUniqueUsersParameter2._(this.value);
@@ -1,23 +1,26 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template patch_adoption_range}
/// The effective (post-clamp) window the response covers.
/// {@template metrics_range}
/// The effective (post-default, post-clamp) window a metrics response
/// or one window of a metrics envelope covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
/// {@endtemplate}
@immutable
class PatchAdoptionRange {
/// {@macro patch_adoption_range}
const PatchAdoptionRange({
class MetricsRange {
/// {@macro metrics_range}
const MetricsRange({
required this.start,
required this.end,
});
/// Converts a `Map<String, dynamic>` to a [PatchAdoptionRange].
factory PatchAdoptionRange.fromJson(Map<String, dynamic> json) {
/// Converts a `Map<String, dynamic>` to a [MetricsRange].
factory MetricsRange.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchAdoptionRange',
'MetricsRange',
json,
() => PatchAdoptionRange(
() => MetricsRange(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
),
@@ -26,11 +29,11 @@ class PatchAdoptionRange {
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchAdoptionRange? maybeFromJson(Map<String, dynamic>? json) {
static MetricsRange? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchAdoptionRange.fromJson(json);
return MetricsRange.fromJson(json);
}
/// Window start (UTC, inclusive).
@@ -39,7 +42,7 @@ class PatchAdoptionRange {
/// Window end (UTC, exclusive).
final DateTime end;
/// Converts a [PatchAdoptionRange] to a `Map<String, dynamic>`.
/// Converts a [MetricsRange] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'start': start.toIso8601String(),
@@ -56,8 +59,6 @@ class PatchAdoptionRange {
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchAdoptionRange &&
start == other.start &&
end == other.end;
return other is MetricsRange && start == other.start && end == other.end;
}
}
@@ -0,0 +1,108 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_breakdown_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
/// {@template unique_users_current_window}
/// The `current` window of the unique-users envelope: the base window
/// atom plus the optional `breakdown`. Only `current` carries a
/// breakdown — no chart renders a previous-window breakdown, so the
/// asymmetry is declared in the contract rather than left as an
/// optional-but-never-populated field.
/// {@endtemplate}
@immutable
class UniqueUsersCurrentWindow {
/// {@macro unique_users_current_window}
const UniqueUsersCurrentWindow({
required this.uniqueUsers,
required this.range,
this.timeSeries,
this.breakdown,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersCurrentWindow].
factory UniqueUsersCurrentWindow.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersCurrentWindow',
json,
() => UniqueUsersCurrentWindow(
uniqueUsers: json['unique_users'] as int,
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
timeSeries: (json['time_series'] as List?)
?.map<UniqueUsersTimeSeriesEntry>(
(e) => UniqueUsersTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
breakdown: (json['breakdown'] as List?)
?.map<UniqueUsersBreakdownEntry>(
(e) =>
UniqueUsersBreakdownEntry.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersCurrentWindow? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersCurrentWindow.fromJson(json);
}
/// Distinct active devices over this window (an HLL count). Note:
/// per-bucket `time_series` values do not sum to this — an HLL merge
/// over the window is not a sum of per-bucket merges.
final int uniqueUsers;
/// The effective (post-default, post-clamp) window a metrics response —
/// or one window of a metrics envelope — covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
final MetricsRange range;
/// Per-bucket series for this window, present only when a
/// `granularity` was requested; otherwise null. On `previous`, also
/// null when the prior window reaches past the plan's metrics-history
/// horizon (the total is still present — only the granular overlay is
/// withheld). Empty buckets are omitted — gap-fill against this
/// window's `range`.
final List<UniqueUsersTimeSeriesEntry>? timeSeries;
/// Per-group unique users for this window, present only when a
/// `group_by` was requested; otherwise null.
final List<UniqueUsersBreakdownEntry>? breakdown;
/// Converts a [UniqueUsersCurrentWindow] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'unique_users': uniqueUsers,
'range': range.toJson(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
'breakdown': breakdown?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
uniqueUsers,
range,
listHash(timeSeries),
listHash(breakdown),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersCurrentWindow &&
uniqueUsers == other.uniqueUsers &&
range == other.range &&
listsEqual(timeSeries, other.timeSeries) &&
listsEqual(breakdown, other.breakdown);
}
}
@@ -1,63 +0,0 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template unique_users_range}
/// The window the unique-users response covers.
/// {@endtemplate}
@immutable
class UniqueUsersRange {
/// {@macro unique_users_range}
const UniqueUsersRange({
required this.start,
required this.end,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersRange].
factory UniqueUsersRange.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersRange',
json,
() => UniqueUsersRange(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersRange? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersRange.fromJson(json);
}
/// Window start (UTC, inclusive).
final DateTime start;
/// Window end (UTC, exclusive).
final DateTime end;
/// Converts a [UniqueUsersRange] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'start': start.toIso8601String(),
'end': end.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
start,
end,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersRange &&
start == other.start &&
end == other.end;
}
}
@@ -0,0 +1,92 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
/// {@template unique_users_window}
/// One window of the unique-users envelope: the HLL-merged total over
/// the window's effective range, with a per-bucket series when a
/// `granularity` was requested. This base atom is the full shape of
/// `previous`; `current` extends it (see UniqueUsersCurrentWindow).
/// {@endtemplate}
@immutable
class UniqueUsersWindow {
/// {@macro unique_users_window}
const UniqueUsersWindow({
required this.uniqueUsers,
required this.range,
this.timeSeries,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersWindow].
factory UniqueUsersWindow.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersWindow',
json,
() => UniqueUsersWindow(
uniqueUsers: json['unique_users'] as int,
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
timeSeries: (json['time_series'] as List?)
?.map<UniqueUsersTimeSeriesEntry>(
(e) => UniqueUsersTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersWindow? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersWindow.fromJson(json);
}
/// Distinct active devices over this window (an HLL count). Note:
/// per-bucket `time_series` values do not sum to this — an HLL merge
/// over the window is not a sum of per-bucket merges.
final int uniqueUsers;
/// The effective (post-default, post-clamp) window a metrics response —
/// or one window of a metrics envelope — covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
final MetricsRange range;
/// Per-bucket series for this window, present only when a
/// `granularity` was requested; otherwise null. On `previous`, also
/// null when the prior window reaches past the plan's metrics-history
/// horizon (the total is still present — only the granular overlay is
/// withheld). Empty buckets are omitted — gap-fill against this
/// window's `range`.
final List<UniqueUsersTimeSeriesEntry>? timeSeries;
/// Converts a [UniqueUsersWindow] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'unique_users': uniqueUsers,
'range': range.toJson(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
uniqueUsers,
range,
listHash(timeSeries),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersWindow &&
uniqueUsers == other.uniqueUsers &&
range == other.range &&
listsEqual(timeSeries, other.timeSeries);
}
}
@@ -9,7 +9,7 @@ void main() {
releaseVersion: 'example',
isLatest: false,
granularity: 'example',
range: PatchAdoptionRange(
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
@@ -6,15 +6,24 @@ void main() {
group('GetUniqueUsersResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersResponse(
uniqueUsers: 0,
granularity: 'example',
range: UniqueUsersRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
asOf: DateTime.utc(2024),
granularity: 'example',
current: UniqueUsersCurrentWindow(
uniqueUsers: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
),
previous: UniqueUsersWindow(
uniqueUsers: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
),
);
final parsed = GetUniqueUsersResponse.maybeFromJson(instance.toJson());
final parsed = GetUniqueUsersResponse.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
@@ -6,7 +6,7 @@ void main() {
group('GetUniqueUsersParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersParameter2.values.first;
final parsed = GetUniqueUsersParameter2.maybeFromJson(instance.toJson());
final parsed = GetUniqueUsersParameter2.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
@@ -6,7 +6,7 @@ void main() {
group('GetUniqueUsersParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersParameter3.values.first;
final parsed = GetUniqueUsersParameter3.maybeFromJson(instance.toJson());
final parsed = GetUniqueUsersParameter3.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
@@ -3,24 +3,24 @@ import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('UniqueUsersRange', () {
group('MetricsRange', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersRange(
final instance = MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
);
final parsed = UniqueUsersRange.maybeFromJson(instance.toJson());
final parsed = MetricsRange.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(UniqueUsersRange.maybeFromJson(null), isNull);
expect(MetricsRange.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => UniqueUsersRange.maybeFromJson(<String, dynamic>{}),
() => MetricsRange.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
@@ -5,14 +5,14 @@ import 'package:test/test.dart';
void main() {
group('UniqueUsersBreakdownEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersBreakdownEntry(
const instance = UniqueUsersBreakdownEntry(
groupBy: 'example',
groupValue: 'example',
uniqueUsers: 0,
);
final parsed = UniqueUsersBreakdownEntry.maybeFromJson(
instance.toJson(),
);
)!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
@@ -0,0 +1,31 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('UniqueUsersCurrentWindow', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersCurrentWindow(
uniqueUsers: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
);
final parsed = UniqueUsersCurrentWindow.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(UniqueUsersCurrentWindow.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => UniqueUsersCurrentWindow.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -11,7 +11,7 @@ void main() {
);
final parsed = UniqueUsersTimeSeriesEntry.maybeFromJson(
instance.toJson(),
);
)!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
@@ -3,24 +3,27 @@ import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchAdoptionRange', () {
group('UniqueUsersWindow', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchAdoptionRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
final instance = UniqueUsersWindow(
uniqueUsers: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
);
final parsed = PatchAdoptionRange.maybeFromJson(instance.toJson())!;
final parsed = UniqueUsersWindow.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchAdoptionRange.maybeFromJson(null), isNull);
expect(UniqueUsersWindow.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchAdoptionRange.maybeFromJson(<String, dynamic>{}),
() => UniqueUsersWindow.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
@@ -0,0 +1,132 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
// The generated round-trip tests cover the required-fields-only case;
// these cover the fully-populated window atoms — in particular the
// allOf-composed current window carrying both `time_series` and
// `breakdown` — and the envelope that nests them.
final range = MetricsRange(
start: DateTime.utc(2026, 4, 22),
end: DateTime.utc(2026, 5, 20),
);
final series = [
UniqueUsersTimeSeriesEntry(
period: DateTime.utc(2026, 4, 22),
uniqueUsers: 10,
),
UniqueUsersTimeSeriesEntry(
period: DateTime.utc(2026, 4, 23),
uniqueUsers: 12,
),
];
group('UniqueUsersWindow', () {
test('round-trips with a populated time_series', () {
final window = UniqueUsersWindow(
uniqueUsers: 42,
range: range,
timeSeries: series,
);
final parsed = UniqueUsersWindow.fromJson(window.toJson());
expect(parsed, equals(window));
expect(parsed.toJson(), equals(window.toJson()));
});
});
group('UniqueUsersCurrentWindow', () {
test('round-trips with time_series and breakdown populated', () {
final window = UniqueUsersCurrentWindow(
uniqueUsers: 42,
range: range,
timeSeries: series,
breakdown: [
UniqueUsersBreakdownEntry(
groupBy: 'platform',
groupValue: 'android',
uniqueUsers: 30,
timeSeries: series,
),
const UniqueUsersBreakdownEntry(
groupBy: 'platform',
groupValue: 'ios',
uniqueUsers: 12,
),
],
);
final parsed = UniqueUsersCurrentWindow.fromJson(window.toJson());
expect(parsed, equals(window));
expect(parsed.toJson(), equals(window.toJson()));
});
});
group('GetUniqueUsersResponse', () {
test('round-trips the full current/previous envelope', () {
final response = GetUniqueUsersResponse(
asOf: DateTime.utc(2026, 5, 20, 17, 30),
granularity: 'day',
current: UniqueUsersCurrentWindow(
uniqueUsers: 42,
range: range,
timeSeries: series,
breakdown: [
UniqueUsersBreakdownEntry(
groupBy: 'platform',
groupValue: 'android',
uniqueUsers: 30,
timeSeries: series,
),
],
),
previous: UniqueUsersWindow(
uniqueUsers: 37,
range: MetricsRange(
start: DateTime.utc(2026, 3, 25),
end: DateTime.utc(2026, 4, 22),
),
timeSeries: series,
),
);
final parsed = GetUniqueUsersResponse.fromJson(response.toJson());
expect(parsed, equals(response));
expect(parsed.toJson(), equals(response.toJson()));
});
test('round-trips the no-granularity envelope (null sections)', () {
final response = GetUniqueUsersResponse(
asOf: DateTime.utc(2026, 5, 20, 17, 30),
granularity: null,
current: UniqueUsersCurrentWindow(uniqueUsers: 42, range: range),
previous: UniqueUsersWindow(
uniqueUsers: 37,
range: MetricsRange(
start: DateTime.utc(2026, 3, 25),
end: DateTime.utc(2026, 4, 22),
),
),
);
final parsed = GetUniqueUsersResponse.fromJson(response.toJson());
expect(parsed, equals(response));
expect(parsed.current.timeSeries, isNull);
expect(parsed.current.breakdown, isNull);
expect(parsed.previous!.timeSeries, isNull);
});
test('round-trips an envelope with previous null', () {
// `previous` is always present but null when the prior window predates
// the data floor (no comparison data); the key is still emitted, so it
// is null on the wire and after a round-trip.
final response = GetUniqueUsersResponse(
asOf: DateTime.utc(2026, 5, 20, 17, 30),
granularity: null,
current: UniqueUsersCurrentWindow(uniqueUsers: 42, range: range),
previous: null,
);
final json = response.toJson();
expect(json['previous'], isNull);
final parsed = GetUniqueUsersResponse.fromJson(json);
expect(parsed, equals(response));
expect(parsed.previous, isNull);
});
});
}