1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use uuid::Uuid;
6
7use crate::{UiSnapshotOmissionReason, UiSnapshotRef};
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
11#[serde(transparent)]
12pub struct DeviceId(pub String);
13
14impl DeviceId {
15 pub fn new(value: impl Into<String>) -> Self {
16 Self(value.into())
17 }
18}
19
20impl fmt::Display for DeviceId {
21 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22 self.0.fmt(formatter)
23 }
24}
25
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
28#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
29pub enum Platform {
30 Web,
31 Android,
32 Ios,
33 HarmonyOs,
34 MacOs,
35 Windows,
36 Linux,
37 Rdp,
38 Mock,
39 Other(String),
40}
41
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct DeviceInfo {
46 pub id: DeviceId,
47 pub name: String,
48 pub platform: Platform,
49 pub os_version: Option<String>,
50 pub connected: bool,
51}
52
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct DevicesListResult {
58 pub devices: Vec<DeviceInfo>,
59 pub selected_device_id: Option<DeviceId>,
60}
61
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct DeviceSelectParams {
67 pub device_id: DeviceId,
68}
69
70#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct DeviceSelectResult {
75 pub device: DeviceInfo,
76}
77
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
80#[serde(rename_all = "camelCase")]
81pub struct Viewport {
82 #[cfg_attr(feature = "schema", schemars(range(max = 4_294_967_295_u64)))]
83 pub width: u32,
84 #[cfg_attr(feature = "schema", schemars(range(max = 4_294_967_295_u64)))]
85 pub height: u32,
86 pub scale_factor: f64,
87}
88
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct AssetRef {
93 pub id: String,
94 pub media_type: String,
95 pub uri: String,
96 pub sha256: Option<String>,
97}
98
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub enum ScreenshotOmissionReason {
103 Policy,
104 ProtectedAction,
105}
106
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
109#[serde(rename_all = "camelCase")]
110pub struct Observation {
111 pub id: Uuid,
112 pub device_id: DeviceId,
113 #[serde(
114 serialize_with = "crate::wire_integer::serialize_js_safe_u64",
115 deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
116 )]
117 #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
118 pub captured_at_ms: u64,
119 pub viewport: Viewport,
120 pub screenshot: Option<AssetRef>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub screenshot_omission: Option<ScreenshotOmissionReason>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub ui_snapshot: Option<UiSnapshotRef>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
127 #[serde(default)]
128 pub metadata: Map<String, Value>,
129}
130
131impl Observation {
132 pub fn asset_refs(&self) -> impl Iterator<Item = &AssetRef> {
134 self.screenshot
135 .iter()
136 .chain(self.ui_snapshot.iter().map(|snapshot| &snapshot.evidence))
137 }
138
139 pub const fn ui_snapshot_state_is_valid(&self) -> bool {
142 !(self.ui_snapshot.is_some() && self.ui_snapshot_omission.is_some())
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use serde_json::{Map, json};
149 use uuid::Uuid;
150
151 use super::{
152 DeviceId, DeviceSelectParams, DeviceSelectResult, DevicesListResult, Observation,
153 ScreenshotOmissionReason, Viewport,
154 };
155
156 #[test]
157 fn device_ids_have_stable_lexical_order() {
158 let mut ids = [
159 DeviceId::new("web-z"),
160 DeviceId::new("android-2"),
161 DeviceId::new("android-10"),
162 ];
163 ids.sort();
164
165 assert_eq!(ids.map(|id| id.0), ["android-10", "android-2", "web-z"]);
166 }
167
168 #[test]
169 fn routing_models_are_strict_and_use_camel_case() {
170 let params: DeviceSelectParams = serde_json::from_value(json!({
171 "deviceId": "android-emulator-5554"
172 }))
173 .expect("select params");
174 assert_eq!(params.device_id, DeviceId::new("android-emulator-5554"));
175 assert!(
176 serde_json::from_value::<DeviceSelectParams>(json!({
177 "deviceId": "android-emulator-5554",
178 "unknown": true
179 }))
180 .is_err()
181 );
182
183 let list: DevicesListResult = serde_json::from_value(json!({
184 "devices": [],
185 "selectedDeviceId": null
186 }))
187 .expect("list result");
188 assert!(list.devices.is_empty());
189 assert!(list.selected_device_id.is_none());
190 assert_eq!(
191 serde_json::to_value(list).expect("serialize list result"),
192 json!({ "devices": [], "selectedDeviceId": null })
193 );
194
195 assert!(
196 serde_json::from_value::<DeviceSelectResult>(json!({
197 "device": {
198 "id": "android-emulator-5554",
199 "name": "Pixel",
200 "platform": { "kind": "android" },
201 "osVersion": "15",
202 "connected": true
203 },
204 "selectedDeviceId": "android-emulator-5554"
205 }))
206 .is_err()
207 );
208 }
209
210 #[test]
211 fn screenshot_omission_is_optional_and_typed() {
212 let base = Observation {
213 id: Uuid::nil(),
214 device_id: DeviceId::new("mock-1"),
215 captured_at_ms: 1,
216 viewport: Viewport {
217 width: 1,
218 height: 1,
219 scale_factor: 1.0,
220 },
221 screenshot: None,
222 screenshot_omission: None,
223 ui_snapshot: None,
224 ui_snapshot_omission: None,
225 metadata: Map::new(),
226 };
227 let legacy = serde_json::to_value(&base).expect("legacy observation");
228 assert!(legacy.get("screenshotOmission").is_none());
229 assert!(legacy.get("uiSnapshot").is_none());
230 assert!(legacy.get("uiSnapshotOmission").is_none());
231
232 let omitted = Observation {
233 screenshot_omission: Some(ScreenshotOmissionReason::ProtectedAction),
234 ..base
235 };
236 assert_eq!(
237 serde_json::to_value(omitted).expect("omitted observation")["screenshotOmission"],
238 "protectedAction"
239 );
240 }
241}