1use oxicode::{Decode, Encode};
27use oxiui_core::{FontSpec, Palette, UiError};
28
29use crate::{DesignTokens, TypographyScale};
30
31#[derive(Clone, Debug, Default, PartialEq, Encode, Decode)]
40pub struct ThemeSnapshot {
41 pub tokens: DesignTokens,
43 pub typography: TypographyScale,
45 pub palette: Palette,
47 pub body_font: FontSpec,
49}
50
51pub fn serialize_theme(snapshot: &ThemeSnapshot) -> Result<Vec<u8>, UiError> {
63 oxicode::encode_to_vec(snapshot)
64 .map_err(|e| UiError::Other(format!("theme serialization failed: {e}")))
65}
66
67pub fn deserialize_theme(bytes: &[u8]) -> Result<ThemeSnapshot, UiError> {
74 let (snapshot, _consumed) = oxicode::decode_from_slice::<ThemeSnapshot>(bytes)
75 .map_err(|e| UiError::Other(format!("theme deserialization failed: {e}")))?;
76 Ok(snapshot)
77}
78
79#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::tokens::{RadiusStep, SpacingStep};
85
86 #[test]
87 fn serialize_deserialize_default_theme_roundtrip() {
88 let original = ThemeSnapshot::default();
89 let bytes = serialize_theme(&original).expect("serialize should succeed");
90 assert!(!bytes.is_empty(), "serialized bytes must be non-empty");
91
92 let restored = deserialize_theme(&bytes).expect("deserialize should succeed");
93 assert_eq!(
94 original, restored,
95 "round-tripped snapshot must equal original"
96 );
97
98 let bytes2 = serialize_theme(&restored).expect("re-serialize should succeed");
100 assert_eq!(
101 bytes, bytes2,
102 "re-serialized bytes must be identical (deterministic encoding)"
103 );
104 }
105
106 #[test]
107 fn deserialize_invalid_bytes_returns_error() {
108 let bad = b"this is not a valid oxicode-encoded theme snapshot";
109 let result = deserialize_theme(bad);
110 assert!(result.is_err(), "invalid bytes must return an error");
111 }
112
113 #[test]
114 fn serialize_is_not_empty() {
115 let snapshot = ThemeSnapshot::default();
116 let bytes = serialize_theme(&snapshot).expect("serialize");
117 assert!(
118 bytes.len() > 10,
119 "serialized theme should be non-trivial in size (got {} bytes)",
120 bytes.len()
121 );
122 }
123
124 #[test]
125 fn roundtrip_custom_tokens() {
126 let custom = ThemeSnapshot {
127 tokens: DesignTokens {
128 spacing: [2.0, 4.0, 8.0, 16.0, 24.0, 32.0, 64.0],
129 radius: [0.0, 3.0, 6.0, 12.0, 24.0, 999.0],
130 elevation: [0.0, 2.0, 4.0, 8.0, 16.0, 32.0],
131 opacity: [0.2, 0.5, 0.7, 0.9, 1.0],
132 },
133 typography: TypographyScale::default(),
134 ..ThemeSnapshot::default()
135 };
136
137 let bytes = serialize_theme(&custom).expect("serialize custom tokens");
138 let restored = deserialize_theme(&bytes).expect("deserialize custom tokens");
139 assert_eq!(custom, restored);
140 }
141
142 #[test]
143 fn named_token_lookup_survives_roundtrip() {
144 let original = ThemeSnapshot::default();
145 let bytes = serialize_theme(&original).expect("serialize");
146 let restored = deserialize_theme(&bytes).expect("deserialize");
147
148 assert_eq!(
150 original.tokens.spacing(SpacingStep::Md),
151 restored.tokens.spacing(SpacingStep::Md),
152 "spacing Md must survive round-trip"
153 );
154 assert_eq!(
155 original.tokens.radius(RadiusStep::Full),
156 restored.tokens.radius(RadiusStep::Full),
157 "radius Full must survive round-trip"
158 );
159 }
160
161 #[test]
162 fn typography_survives_roundtrip() {
163 let original = ThemeSnapshot::default();
164 let bytes = serialize_theme(&original).expect("serialize");
165 let restored = deserialize_theme(&bytes).expect("deserialize");
166
167 assert_eq!(
168 original.typography.body.size, restored.typography.body.size,
169 "body size must survive round-trip"
170 );
171 assert_eq!(
172 original.typography.display.weight, restored.typography.display.weight,
173 "display weight must survive round-trip"
174 );
175 }
176
177 #[test]
178 fn palette_round_trip() {
179 use oxiui_core::{Color, Palette};
180 let snapshot = ThemeSnapshot {
181 palette: Palette {
182 background: Color(10, 20, 30, 255),
183 surface: Color(11, 21, 31, 255),
184 primary: Color(12, 22, 32, 255),
185 on_primary: Color(13, 23, 33, 255),
186 text: Color(14, 24, 34, 255),
187 muted: Color(15, 25, 35, 255),
188 },
189 ..ThemeSnapshot::default()
190 };
191 let bytes = serialize_theme(&snapshot).expect("serialize");
192 let decoded = deserialize_theme(&bytes).expect("deserialize");
193 assert_eq!(snapshot, decoded);
194 }
195
196 #[test]
197 fn font_style_oblique_round_trip() {
198 use oxiui_core::{FontSpec, FontStyle};
199 let snapshot = ThemeSnapshot {
200 body_font: FontSpec {
201 family: "MyFont".to_string(),
202 size: 18.0,
203 weight: 700,
204 style: FontStyle::Oblique { degrees: 12.5 },
205 letter_spacing: 0.5,
206 line_height: Some(1.6),
207 features: vec![],
208 },
209 ..ThemeSnapshot::default()
210 };
211 let bytes = serialize_theme(&snapshot).expect("serialize");
212 let decoded = deserialize_theme(&bytes).expect("deserialize");
213 assert_eq!(snapshot, decoded);
214 }
215
216 #[test]
217 fn full_snapshot_round_trip() {
218 use oxiui_core::{Color, FontSpec, FontStyle, Palette};
219 let snapshot = ThemeSnapshot {
220 palette: Palette {
221 background: Color(255, 255, 255, 255),
222 surface: Color(245, 245, 245, 255),
223 primary: Color(99, 102, 241, 255),
224 on_primary: Color(255, 255, 255, 255),
225 text: Color(15, 23, 42, 255),
226 muted: Color(100, 116, 139, 255),
227 },
228 body_font: FontSpec {
229 family: "Inter".to_string(),
230 size: 16.0,
231 weight: 400,
232 style: FontStyle::Normal,
233 letter_spacing: 0.0,
234 line_height: Some(1.5),
235 features: vec![],
236 },
237 ..ThemeSnapshot::default()
238 };
239 let bytes = serialize_theme(&snapshot).expect("serialize");
240 let decoded = deserialize_theme(&bytes).expect("deserialize");
241 assert_eq!(snapshot, decoded);
242 }
243}