tauri_plugin_widgets/adaptive_card/
mod.rs1mod data;
13mod interactive;
14mod layout;
15mod media;
16mod spacing;
17mod style;
18mod text;
19
20use crate::models::{WidgetConfig, WidgetElement};
21use crate::receipt::SkippedElement;
22use serde_json::{json, Value};
23
24pub(crate) use style::sf_symbol_glyph_public;
25
26#[derive(Debug, Clone)]
28pub struct TranspileResult {
29 pub card: Value,
30 pub skipped: Vec<SkippedElement>,
31}
32
33pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult {
35 let mut skipped = Vec::new();
36 let body = el(root, &mut skipped);
37 let card = json!({
38 "type": "AdaptiveCard",
39 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
40 "version": "1.5",
41 "body": [body],
42 });
43 TranspileResult { card, skipped }
44}
45
46pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option<TranspileResult> {
48 let root = match size {
49 "large" => config
50 .large
51 .as_ref()
52 .or(config.small.as_ref())
53 .or(config.medium.as_ref()),
54 "medium" => config
55 .medium
56 .as_ref()
57 .or(config.small.as_ref())
58 .or(config.large.as_ref()),
59 _ => config
60 .small
61 .as_ref()
62 .or(config.medium.as_ref())
63 .or(config.large.as_ref()),
64 }?;
65 Some(to_adaptive_card(root))
66}
67
68pub fn validate_card_structure(card: &Value) -> Result<(), String> {
70 let obj = card
71 .as_object()
72 .ok_or_else(|| "card must be an object".to_string())?;
73 if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") {
74 return Err("type must be AdaptiveCard".into());
75 }
76 if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") {
77 return Err("version must be 1.5".into());
78 }
79 match obj.get("body") {
80 Some(Value::Array(_)) => Ok(()),
81 _ => Err("body must be an array".into()),
82 }
83}
84
85pub fn ac_template_key(widget_id: &str) -> String {
87 format!("ac:template:{widget_id}")
88}
89
90pub fn ac_data_key(widget_id: &str) -> String {
91 format!("ac:data:{widget_id}")
92}
93
94fn el(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
96 match e {
97 WidgetElement::VStack(v) => layout::vstack(v, skipped),
99 WidgetElement::HStack(h) => layout::hstack(h, skipped),
100 WidgetElement::Container(c) => layout::container(c, skipped),
101 WidgetElement::Grid(g) => layout::grid(g, skipped),
102 WidgetElement::ZStack(z) => layout::zstack(e, z, skipped),
103
104 WidgetElement::Text(t) => text::text(t),
106 WidgetElement::Date(d) => text::date(d),
107 WidgetElement::Timer(t) => text::timer(t),
108 WidgetElement::Label(l) => text::label(l),
109
110 WidgetElement::Image(i) => media::image(i),
112 WidgetElement::Shape(_) => media::shape(e, skipped),
113 WidgetElement::Canvas(_) => media::canvas(e, skipped),
114
115 WidgetElement::Progress(p) => data::progress(e, p, skipped),
117 WidgetElement::Gauge(_) => data::gauge(e, skipped),
118 WidgetElement::Chart(_) => data::chart(e, skipped),
119 WidgetElement::List(l) => data::list(l),
120
121 WidgetElement::Button(b) => interactive::button(b),
123 WidgetElement::Toggle(t) => interactive::toggle(t),
124 WidgetElement::Link(l) => interactive::link(l, skipped),
125
126 WidgetElement::Spacer(_) => spacing::spacer(),
128 WidgetElement::Divider(d) => spacing::divider(d),
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use std::fs;
136 use std::path::PathBuf;
137
138 fn fixtures_dir() -> PathBuf {
139 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
140 }
141
142 fn snapshots_dir() -> PathBuf {
143 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive")
144 }
145
146 fn load_config(rel: &str) -> WidgetConfig {
147 let path = fixtures_dir().join(rel);
148 let raw =
149 fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
150 let v: Value = serde_json::from_str(&raw).expect("json");
152 serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}"))
153 }
154
155 #[test]
156 fn weather_small_is_valid_v15() {
157 let cfg = load_config("presets/weather.json");
158 let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout");
159 validate_card_structure(&result.card).unwrap();
160 assert_eq!(result.card["body"].as_array().unwrap().len(), 1);
161 assert_eq!(result.card["body"][0]["type"], "Container");
162 }
163
164 #[test]
165 fn unsupported_nodes_are_skipped() {
166 let cfg: WidgetConfig = serde_json::from_value(json!({
167 "version": 1,
168 "small": {
169 "type": "vstack",
170 "children": [
171 { "type": "text", "content": "hi" },
172 { "type": "canvas", "width": 10, "height": 10, "elements": [] },
173 { "type": "zstack", "children": [
174 { "type": "text", "content": "a" }
175 ] }
176 ]
177 }
178 }))
179 .unwrap();
180 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
181 let body = serde_json::to_string(&result.card["body"]).unwrap();
183 assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock"));
184 validate_card_structure(&result.card).unwrap();
185 }
186
187 #[test]
188 #[cfg(feature = "rasterize")]
189 fn chart_becomes_image_data_uri() {
190 let cfg: WidgetConfig = serde_json::from_value(json!({
191 "version": 1,
192 "small": {
193 "type": "chart",
194 "chartType": "bar",
195 "chartData": [
196 { "label": "a", "value": 1 },
197 { "label": "b", "value": 2 }
198 ]
199 }
200 }))
201 .unwrap();
202 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
203 assert!(result.skipped.is_empty(), "{:?}", result.skipped);
204 assert_eq!(result.card["body"][0]["type"], "Image");
205 let url = result.card["body"][0]["url"].as_str().unwrap();
206 assert!(url.starts_with("data:image/png;base64,"));
207 }
208
209 #[test]
210 fn weather_small_matches_golden_or_writes_hint() {
211 let cfg = load_config("presets/weather.json");
212 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
213 let golden_path = snapshots_dir().join("weather.small.json");
214 if !golden_path.exists() {
215 fs::create_dir_all(snapshots_dir()).unwrap();
216 let pretty = serde_json::to_string_pretty(&result.card).unwrap();
217 fs::write(&golden_path, pretty).unwrap();
218 validate_card_structure(&result.card).unwrap();
220 return;
221 }
222 let expected: Value =
223 serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap();
224 assert_eq!(
225 result.card, expected,
226 "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional"
227 );
228 }
229
230 #[test]
231 fn cases_transpile_without_panic() {
232 let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases");
233 let Ok(entries) = fs::read_dir(&cases_dir) else {
234 return;
235 };
236 for entry in entries.flatten() {
237 let path = entry.path();
238 if path.extension().and_then(|e| e.to_str()) != Some("json") {
239 continue;
240 }
241 let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
242 let fixture = case["fixture"].as_str().unwrap_or("");
243 let size = case["size"].as_str().unwrap_or("small");
244 let cfg_path = fixtures_dir().join(format!("{fixture}.json"));
245 if !cfg_path.exists() {
246 continue;
247 }
248 let cfg = load_config(&format!("{fixture}.json"));
249 let Some(result) = to_adaptive_card_for_size(&cfg, size) else {
250 continue;
251 };
252 validate_card_structure(&result.card)
253 .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
254 }
255 }
256}