From 6fc2d8dfa3fba47dd81ca648d6b1730121b3015c Mon Sep 17 00:00:00 2001 From: Mac Date: Tue, 16 Jun 2026 17:45:19 -0600 Subject: [PATCH] feat(shorebird_code_push_protocol): unique-users current/previous envelope + shared MetricsRange [1/5] (#3821) --- .../lib/shorebird_code_push_protocol.dart | 5 +- .../get_patch_adoption_response.dart | 15 +- .../get_unique_users_response.dart | 101 ++++++-------- .../models/get_patch_adoption_parameter3.dart | 3 +- .../models/get_unique_users_parameter2.dart | 3 +- ...adoption_range.dart => metrics_range.dart} | 31 ++-- .../models/unique_users_current_window.dart | 108 ++++++++++++++ .../lib/src/models/unique_users_range.dart | 63 --------- .../lib/src/models/unique_users_window.dart | 92 ++++++++++++ .../get_patch_adoption_response_test.dart | 2 +- .../get_unique_users_response_test.dart | 23 ++- .../get_unique_users_parameter2_test.dart | 2 +- .../get_unique_users_parameter3_test.dart | 2 +- ...ange_test.dart => metrics_range_test.dart} | 10 +- .../unique_users_breakdown_entry_test.dart | 4 +- .../unique_users_current_window_test.dart | 31 ++++ .../unique_users_time_series_entry_test.dart | 2 +- ...est.dart => unique_users_window_test.dart} | 17 ++- .../src/models/unique_users_window_test.dart | 132 ++++++++++++++++++ 19 files changed, 474 insertions(+), 172 deletions(-) rename packages/shorebird_code_push_protocol/lib/src/models/{patch_adoption_range.dart => metrics_range.dart} (55%) create mode 100644 packages/shorebird_code_push_protocol/lib/src/models/unique_users_current_window.dart delete mode 100644 packages/shorebird_code_push_protocol/lib/src/models/unique_users_range.dart create mode 100644 packages/shorebird_code_push_protocol/lib/src/models/unique_users_window.dart rename packages/shorebird_code_push_protocol/test/generated/models/{unique_users_range_test.dart => metrics_range_test.dart} (69%) create mode 100644 packages/shorebird_code_push_protocol/test/generated/models/unique_users_current_window_test.dart rename packages/shorebird_code_push_protocol/test/generated/models/{patch_adoption_range_test.dart => unique_users_window_test.dart} (56%) create mode 100644 packages/shorebird_code_push_protocol/test/src/models/unique_users_window_test.dart diff --git a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart index e15975bb..6f970a76 100644 --- a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart +++ b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart @@ -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. diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_adoption/get_patch_adoption_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_adoption/get_patch_adoption_response.dart index ee21c3ad..dac3b058 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_adoption/get_patch_adoption_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_patch_adoption/get_patch_adoption_response.dart @@ -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, - ), + range: MetricsRange.fromJson(json['range'] as Map), asOf: DateTime.parse(json['as_of'] as String), patches: (json['patches'] as List) .map( @@ -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 diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_unique_users/get_unique_users_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_unique_users/get_unique_users_response.dart index 07eccdb4..c03505a5 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/get_unique_users/get_unique_users_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_unique_users/get_unique_users_response.dart @@ -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` 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), asOf: DateTime.parse(json['as_of'] as String), - timeSeries: (json['time_series'] as List?) - ?.map( - (e) => UniqueUsersTimeSeriesEntry.fromJson( - e as Map, - ), - ) - .toList(), - breakdown: (json['breakdown'] as List?) - ?.map( - (e) => - UniqueUsersBreakdownEntry.fromJson(e as Map), - ) - .toList(), + granularity: checkedKey(json, 'granularity') as String?, + current: UniqueUsersCurrentWindow.fromJson( + json['current'] as Map, + ), + previous: UniqueUsersWindow.maybeFromJson( + checkedKey(json, 'previous') as Map?, + ), ), ); } @@ -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? 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? 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`. Map 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; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_patch_adoption_parameter3.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_patch_adoption_parameter3.dart index 119c9d0d..44e7e32c 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/get_patch_adoption_parameter3.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_patch_adoption_parameter3.dart @@ -1,8 +1,7 @@ enum GetPatchAdoptionParameter3 { hour._('hour'), day._('day'), - week._('week'), - month._('month'); + week._('week'); const GetPatchAdoptionParameter3._(this.value); diff --git a/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter2.dart b/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter2.dart index 5676df55..08be9845 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter2.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/get_unique_users_parameter2.dart @@ -1,8 +1,7 @@ enum GetUniqueUsersParameter2 { hour._('hour'), day._('day'), - week._('week'), - month._('month'); + week._('week'); const GetUniqueUsersParameter2._(this.value); diff --git a/packages/shorebird_code_push_protocol/lib/src/models/patch_adoption_range.dart b/packages/shorebird_code_push_protocol/lib/src/models/metrics_range.dart similarity index 55% rename from packages/shorebird_code_push_protocol/lib/src/models/patch_adoption_range.dart rename to packages/shorebird_code_push_protocol/lib/src/models/metrics_range.dart index a934cd84..45960e47 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/patch_adoption_range.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/metrics_range.dart @@ -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` to a [PatchAdoptionRange]. - factory PatchAdoptionRange.fromJson(Map json) { + /// Converts a `Map` to a [MetricsRange]. + factory MetricsRange.fromJson(Map 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? json) { + static MetricsRange? maybeFromJson(Map? 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`. + /// Converts a [MetricsRange] to a `Map`. Map 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; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_current_window.dart b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_current_window.dart new file mode 100644 index 00000000..eda0b73a --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_current_window.dart @@ -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` to a [UniqueUsersCurrentWindow]. + factory UniqueUsersCurrentWindow.fromJson(Map json) { + return parseFromJson( + 'UniqueUsersCurrentWindow', + json, + () => UniqueUsersCurrentWindow( + uniqueUsers: json['unique_users'] as int, + range: MetricsRange.fromJson(json['range'] as Map), + timeSeries: (json['time_series'] as List?) + ?.map( + (e) => UniqueUsersTimeSeriesEntry.fromJson( + e as Map, + ), + ) + .toList(), + breakdown: (json['breakdown'] as List?) + ?.map( + (e) => + UniqueUsersBreakdownEntry.fromJson(e as Map), + ) + .toList(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static UniqueUsersCurrentWindow? maybeFromJson(Map? 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? timeSeries; + + /// Per-group unique users for this window, present only when a + /// `group_by` was requested; otherwise null. + final List? breakdown; + + /// Converts a [UniqueUsersCurrentWindow] to a `Map`. + Map 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); + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_range.dart b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_range.dart deleted file mode 100644 index e986bd8d..00000000 --- a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_range.dart +++ /dev/null @@ -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` to a [UniqueUsersRange]. - factory UniqueUsersRange.fromJson(Map 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? 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`. - Map 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; - } -} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/unique_users_window.dart b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_window.dart new file mode 100644 index 00000000..15213ad0 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/unique_users_window.dart @@ -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` to a [UniqueUsersWindow]. + factory UniqueUsersWindow.fromJson(Map json) { + return parseFromJson( + 'UniqueUsersWindow', + json, + () => UniqueUsersWindow( + uniqueUsers: json['unique_users'] as int, + range: MetricsRange.fromJson(json['range'] as Map), + timeSeries: (json['time_series'] as List?) + ?.map( + (e) => UniqueUsersTimeSeriesEntry.fromJson( + e as Map, + ), + ) + .toList(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static UniqueUsersWindow? maybeFromJson(Map? 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? timeSeries; + + /// Converts a [UniqueUsersWindow] to a `Map`. + Map 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); + } +} diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_adoption/get_patch_adoption_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_adoption/get_patch_adoption_response_test.dart index 2c0671f2..07a6a809 100644 --- a/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_adoption/get_patch_adoption_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_patch_adoption/get_patch_adoption_response_test.dart @@ -9,7 +9,7 @@ void main() { releaseVersion: 'example', isLatest: false, granularity: 'example', - range: PatchAdoptionRange( + range: MetricsRange( start: DateTime.utc(2024), end: DateTime.utc(2024), ), diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_unique_users/get_unique_users_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_unique_users/get_unique_users_response_test.dart index ccd26b09..747864af 100644 --- a/packages/shorebird_code_push_protocol/test/generated/messages/get_unique_users/get_unique_users_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_unique_users/get_unique_users_response_test.dart @@ -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)); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter2_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter2_test.dart index 62eae97f..e4bfe6d7 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter2_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter2_test.dart @@ -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)); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter3_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter3_test.dart index c98492ff..89fec8a9 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter3_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/get_unique_users_parameter3_test.dart @@ -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)); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_range_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/metrics_range_test.dart similarity index 69% rename from packages/shorebird_code_push_protocol/test/generated/models/unique_users_range_test.dart rename to packages/shorebird_code_push_protocol/test/generated/models/metrics_range_test.dart index afe37f73..27fc6f38 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_range_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/metrics_range_test.dart @@ -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({}), + () => MetricsRange.maybeFromJson({}), throwsFormatException, ); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_breakdown_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_breakdown_entry_test.dart index fa23400f..6918caf0 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_breakdown_entry_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_breakdown_entry_test.dart @@ -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)); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_current_window_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_current_window_test.dart new file mode 100644 index 00000000..a7b6bc46 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_current_window_test.dart @@ -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({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_time_series_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_time_series_entry_test.dart index 11316624..8759d857 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/unique_users_time_series_entry_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_time_series_entry_test.dart @@ -11,7 +11,7 @@ void main() { ); final parsed = UniqueUsersTimeSeriesEntry.maybeFromJson( instance.toJson(), - ); + )!; expect(parsed, equals(instance)); expect(parsed.hashCode, equals(instance.hashCode)); }); diff --git a/packages/shorebird_code_push_protocol/test/generated/models/patch_adoption_range_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_window_test.dart similarity index 56% rename from packages/shorebird_code_push_protocol/test/generated/models/patch_adoption_range_test.dart rename to packages/shorebird_code_push_protocol/test/generated/models/unique_users_window_test.dart index 2ddb6ea3..16621c91 100644 --- a/packages/shorebird_code_push_protocol/test/generated/models/patch_adoption_range_test.dart +++ b/packages/shorebird_code_push_protocol/test/generated/models/unique_users_window_test.dart @@ -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({}), + () => UniqueUsersWindow.maybeFromJson({}), throwsFormatException, ); }); diff --git a/packages/shorebird_code_push_protocol/test/src/models/unique_users_window_test.dart b/packages/shorebird_code_push_protocol/test/src/models/unique_users_window_test.dart new file mode 100644 index 00000000..20fdb3cc --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/src/models/unique_users_window_test.dart @@ -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); + }); + }); +}