1use serde::{Deserialize, Serialize};
8#[cfg(feature = "ts")]
9use ts_rs::TS;
10
11pub mod io_container;
14
15pub mod node_types;
17
18pub mod parameters;
20
21pub use node_types::{all_node_types, node_type_params};
23pub use parameters::{
24 Constraints, OptionEntry, ParamCondition, ParamConditionEntry, ParameterDef, ParameterType,
25 PresetEntry,
26};
27
28#[cfg_attr(feature = "ts", derive(TS))]
32#[cfg_attr(
33 feature = "ts",
34 ts(
35 export,
36 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
37 )
38)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
40#[serde(rename_all = "camelCase")]
41pub enum InputCardinality {
42 #[default]
44 PerFile,
45 Batch,
47 Source,
49}
50
51#[derive(Debug, Clone, Serialize, PartialEq)]
55#[serde(rename_all = "kebab-case")]
56pub enum NodeCategory {
57 Image,
58 Spreadsheet,
59 File,
60 Data,
61 Network,
62 Control,
63 System,
64 Vector,
65 Video,
66 Io,
67}
68
69#[derive(Debug, Clone, Serialize, PartialEq)]
74#[serde(rename_all = "camelCase")]
75pub struct NodeTypeInfo {
76 pub name: String,
77 pub label: String,
78 pub description: String,
79 pub category: NodeCategory,
80 pub is_container: bool,
81 pub platforms: Vec<String>,
82 pub icon: String,
84}
85
86#[cfg_attr(feature = "ts", derive(TS))]
90#[cfg_attr(
91 feature = "ts",
92 ts(
93 export,
94 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
95 )
96)]
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98#[serde(rename_all = "camelCase")]
99pub struct Dependency {
100 pub binary: String,
101 #[serde(default, skip_serializing_if = "String::is_empty")]
102 pub version: String,
103 pub install_hint: String,
104 #[serde(default, skip_serializing_if = "String::is_empty")]
105 pub homepage: String,
106}
107
108#[derive(Debug, Clone, Serialize, PartialEq)]
113#[serde(rename_all = "camelCase")]
114pub struct NodeMetadata {
115 pub node_type: std::string::String,
116 pub name: std::string::String,
117 pub description: std::string::String,
118 pub category: NodeCategory,
119 pub accepts: Vec<std::string::String>,
120 pub platforms: Vec<std::string::String>,
121 pub parameters: Vec<ParameterDef>,
122 #[serde(default)]
123 pub input_cardinality: InputCardinality,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub requires: Vec<Dependency>,
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
135 fn test_input_cardinality_defaults_to_per_file() {
136 let cardinality = InputCardinality::default();
137 assert_eq!(cardinality, InputCardinality::PerFile);
138 }
139
140 #[test]
141 fn test_input_cardinality_serializes_camel_case() {
142 let per_file = serde_json::to_string(&InputCardinality::PerFile).unwrap();
143 assert_eq!(per_file, r#""perFile""#);
144
145 let batch = serde_json::to_string(&InputCardinality::Batch).unwrap();
146 assert_eq!(batch, r#""batch""#);
147
148 let source = serde_json::to_string(&InputCardinality::Source).unwrap();
149 assert_eq!(source, r#""source""#);
150 }
151
152 #[test]
153 fn test_metadata_with_input_cardinality_round_trip() {
154 let metadata = NodeMetadata {
155 node_type: "image-compress".to_string(),
156 name: "Compress Images".to_string(),
157 description: "Reduce image file size".to_string(),
158 category: NodeCategory::Image,
159 accepts: vec!["image/jpeg".to_string()],
160 platforms: vec!["browser".to_string()],
161 parameters: vec![],
162 input_cardinality: InputCardinality::PerFile,
163 requires: vec![],
164 };
165 let json = serde_json::to_string(&metadata).unwrap();
166 assert!(json.contains(r#""inputCardinality":"perFile""#));
167
168 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
169 assert_eq!(parsed["inputCardinality"], "perFile");
170 }
171
172 #[test]
173 fn test_metadata_with_batch_cardinality() {
174 let metadata = NodeMetadata {
175 node_type: "zip-files".to_string(),
176 name: "Zip Files".to_string(),
177 description: "Bundle files into a zip archive".to_string(),
178 category: NodeCategory::File,
179 accepts: vec![],
180 platforms: vec!["browser".to_string()],
181 parameters: vec![],
182 input_cardinality: InputCardinality::Batch,
183 requires: vec![],
184 };
185 let json = serde_json::to_string(&metadata).unwrap();
186 assert!(json.contains(r#""inputCardinality":"batch""#));
187 }
188
189 #[test]
192 fn test_dependency_deserializes_from_json() {
193 let json = r#"{
194 "binary": "yt-dlp",
195 "installHint": "brew install yt-dlp",
196 "homepage": "https://github.com/yt-dlp/yt-dlp"
197 }"#;
198 let dep: Dependency = serde_json::from_str(json).unwrap();
199 assert_eq!(dep.binary, "yt-dlp");
200 assert_eq!(dep.install_hint, "brew install yt-dlp");
201 assert_eq!(dep.homepage, "https://github.com/yt-dlp/yt-dlp");
202 assert!(dep.version.is_empty());
203 }
204
205 #[test]
206 fn test_dependency_deserializes_with_version() {
207 let json = r#"{
208 "binary": "ffmpeg",
209 "version": ">=6.0",
210 "installHint": "brew install ffmpeg"
211 }"#;
212 let dep: Dependency = serde_json::from_str(json).unwrap();
213 assert_eq!(dep.binary, "ffmpeg");
214 assert_eq!(dep.version, ">=6.0");
215 assert!(dep.homepage.is_empty());
216 }
217
218 #[test]
219 fn test_dependency_round_trip() {
220 let original = Dependency {
221 binary: "yt-dlp".to_string(),
222 version: ">=2024.0.0".to_string(),
223 install_hint: "brew install yt-dlp".to_string(),
224 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
225 };
226 let json = serde_json::to_string(&original).unwrap();
227 let round_tripped: Dependency = serde_json::from_str(&json).unwrap();
228 assert_eq!(original, round_tripped);
229 }
230
231 #[test]
232 fn test_dependency_empty_optional_fields_omitted_in_serialization() {
233 let dep = Dependency {
234 binary: "curl".to_string(),
235 version: String::new(),
236 install_hint: "brew install curl".to_string(),
237 homepage: String::new(),
238 };
239 let json = serde_json::to_string(&dep).unwrap();
240 assert!(!json.contains("version"), "Empty version should be omitted");
241 assert!(
242 !json.contains("homepage"),
243 "Empty homepage should be omitted"
244 );
245 assert!(json.contains("binary"));
246 assert!(json.contains("installHint"));
247 }
248
249 #[test]
252 fn test_all_node_types_returns_26_entries() {
253 let types = all_node_types();
254 assert_eq!(types.len(), 26, "Should have exactly 26 node types");
255 }
256
257 #[test]
258 fn test_all_node_types_sorted_alphabetically() {
259 let types = all_node_types();
260 let names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
261 let mut sorted = names.clone();
262 sorted.sort();
263 assert_eq!(names, sorted, "Node types should be alphabetically sorted");
264 }
265
266 #[test]
267 fn test_all_node_types_unique_names() {
268 let types = all_node_types();
269 let mut names: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect();
270 names.sort();
271 names.dedup();
272 assert_eq!(names.len(), 26, "All node type names should be unique");
273 }
274
275 #[test]
276 fn test_container_types_are_group_loop_parallel() {
277 let types = all_node_types();
278 let mut containers: Vec<&str> = types
279 .iter()
280 .filter(|t| t.is_container)
281 .map(|t| t.name.as_str())
282 .collect();
283 containers.sort();
284 assert_eq!(containers, vec!["group", "loop", "parallel"]);
285 }
286
287 #[test]
288 fn test_io_types_are_input_output() {
289 let types = all_node_types();
290 let mut io_types: Vec<&str> = types
291 .iter()
292 .filter(|t| t.category == NodeCategory::Io)
293 .map(|t| t.name.as_str())
294 .collect();
295 io_types.sort();
296 assert_eq!(io_types, vec!["input", "output"]);
297 }
298
299 #[test]
300 fn test_server_only_types() {
301 let types = all_node_types();
302 let mut server_only: Vec<&str> = types
303 .iter()
304 .filter(|t| !t.platforms.contains(&"browser".to_string()))
305 .map(|t| t.name.as_str())
306 .collect();
307 server_only.sort();
308 assert_eq!(
309 server_only,
310 vec!["file-collect", "file-copy", "http-request", "shell-command"]
311 );
312 }
313
314 #[test]
315 fn test_node_type_info_serializes_camel_case() {
316 let info = NodeTypeInfo {
317 name: "image".to_string(),
318 label: "Image".to_string(),
319 description: "Image processing".to_string(),
320 category: NodeCategory::Image,
321 is_container: false,
322 platforms: vec!["browser".to_string()],
323 icon: "image".to_string(),
324 };
325 let json = serde_json::to_string(&info).unwrap();
326 assert!(json.contains(r#""isContainer":false"#));
327 assert!(!json.contains("is_container"));
328 }
329
330 #[test]
333 fn test_category_serializes_to_kebab_case() {
334 let json = serde_json::to_string(&NodeCategory::Image).unwrap();
335 assert_eq!(json, r#""image""#);
336
337 let json = serde_json::to_string(&NodeCategory::Spreadsheet).unwrap();
338 assert_eq!(json, r#""spreadsheet""#);
339
340 let json = serde_json::to_string(&NodeCategory::File).unwrap();
341 assert_eq!(json, r#""file""#);
342
343 let json = serde_json::to_string(&NodeCategory::Io).unwrap();
344 assert_eq!(json, r#""io""#);
345
346 let json = serde_json::to_string(&NodeCategory::Vector).unwrap();
347 assert_eq!(json, r#""vector""#);
348
349 let json = serde_json::to_string(&NodeCategory::Video).unwrap();
350 assert_eq!(json, r#""video""#);
351 }
352
353 #[test]
354 fn test_parameter_type_number_serialization() {
355 let json = serde_json::to_string(&ParameterType::Number).unwrap();
356 assert_eq!(json, r#"{"type":"number"}"#);
357 }
358
359 #[test]
360 fn test_parameter_type_enum_serialization() {
361 let param = ParameterType::Enum {
362 options: vec![
363 OptionEntry {
364 value: "jpeg".to_string(),
365 label: "JPEG".to_string(),
366 },
367 OptionEntry {
368 value: "png".to_string(),
369 label: "PNG".to_string(),
370 },
371 OptionEntry {
372 value: "webp".to_string(),
373 label: "WebP".to_string(),
374 },
375 ],
376 };
377 let json = serde_json::to_string(¶m).unwrap();
378 assert!(json.contains(r#""type":"enum""#));
379 assert!(json.contains(r#""value":"jpeg""#));
380 assert!(json.contains(r#""label":"JPEG""#));
381 assert!(json.contains(r#""value":"webp""#));
382 assert!(json.contains(r#""label":"WebP""#));
383 }
384
385 #[test]
386 fn test_constraints_skips_none_fields() {
387 let constraints = Constraints {
388 min: Some(1.0),
389 max: None,
390 required: false,
391 };
392 let json = serde_json::to_string(&constraints).unwrap();
393 assert!(json.contains(r#""min":1.0"#));
394 assert!(!json.contains("max"));
395 assert!(json.contains(r#""required":false"#));
396 }
397
398 #[test]
399 fn test_constraints_includes_all_fields_when_present() {
400 let constraints = Constraints {
401 min: Some(1.0),
402 max: Some(100.0),
403 required: true,
404 };
405 let json = serde_json::to_string(&constraints).unwrap();
406 assert!(json.contains(r#""min":1.0"#));
407 assert!(json.contains(r#""max":100.0"#));
408 assert!(json.contains(r#""required":true"#));
409 }
410
411 #[test]
412 fn test_parameter_def_serializes_camel_case() {
413 let param = ParameterDef {
414 name: "quality".to_string(),
415 label: "Quality".to_string(),
416 description: "Compression quality".to_string(),
417 param_type: ParameterType::Number,
418 default: Some(serde_json::json!(80)),
419 constraints: Some(Constraints {
420 min: Some(1.0),
421 max: Some(100.0),
422 required: false,
423 }),
424 ..Default::default()
425 };
426 let json = serde_json::to_string(¶m).unwrap();
427 assert!(json.contains(r#""paramType""#));
428 assert!(!json.contains("param_type"));
429 }
430
431 #[test]
432 fn test_parameter_def_skips_none_default() {
433 let param = ParameterDef {
434 name: "width".to_string(),
435 label: "Width".to_string(),
436 description: "Target width".to_string(),
437 param_type: ParameterType::Number,
438 ..Default::default()
439 };
440 let json = serde_json::to_string(¶m).unwrap();
441 assert!(!json.contains("default"));
442 assert!(!json.contains("constraints"));
443 assert!(!json.contains("placeholder"));
444 assert!(!json.contains("visibleWhen"));
445 assert!(!json.contains("requiredWhen"));
446 }
447
448 #[test]
449 fn test_parameter_def_surfaceable_defaults_to_true() {
450 let param = ParameterDef {
451 name: "quality".to_string(),
452 label: "Quality".to_string(),
453 description: "Compression quality".to_string(),
454 param_type: ParameterType::Number,
455 ..Default::default()
456 };
457 assert!(param.surfaceable, "surfaceable should default to true");
458 let json = serde_json::to_string(¶m).unwrap();
459 assert!(json.contains(r#""surfaceable":true"#));
460 }
461
462 #[test]
463 fn test_parameter_def_surfaceable_false_serializes() {
464 let param = ParameterDef {
465 name: "items".to_string(),
466 label: "Items".to_string(),
467 description: "Template expression for iteration items".to_string(),
468 param_type: ParameterType::String,
469 surfaceable: false,
470 ..Default::default()
471 };
472 assert!(!param.surfaceable);
473 let json = serde_json::to_string(¶m).unwrap();
474 assert!(json.contains(r#""surfaceable":false"#));
475 }
476
477 #[test]
478 fn test_node_metadata_serializes_camel_case() {
479 let metadata = NodeMetadata {
480 node_type: "image-compress".to_string(),
481 name: "Compress Images".to_string(),
482 description: "Reduce image file size".to_string(),
483 category: NodeCategory::Image,
484 accepts: vec![
485 "image/jpeg".to_string(),
486 "image/png".to_string(),
487 "image/webp".to_string(),
488 ],
489 platforms: vec!["browser".to_string()],
490 parameters: vec![],
491 input_cardinality: InputCardinality::PerFile,
492 requires: vec![],
493 };
494 let json = serde_json::to_string(&metadata).unwrap();
495 assert!(json.contains(r#""nodeType":"image-compress""#));
496 assert!(json.contains(r#""platforms":["browser"]"#));
497 assert!(!json.contains("node_type"));
498 }
499
500 #[test]
501 fn test_full_metadata_round_trip() {
502 let metadata = NodeMetadata {
503 node_type: "image-compress".to_string(),
504 name: "Compress Images".to_string(),
505 description: "Reduce image file size while maintaining quality".to_string(),
506 category: NodeCategory::Image,
507 accepts: vec![
508 "image/jpeg".to_string(),
509 "image/png".to_string(),
510 "image/webp".to_string(),
511 ],
512 platforms: vec!["browser".to_string()],
513 parameters: vec![ParameterDef {
514 name: "quality".to_string(),
515 label: "Quality".to_string(),
516 description: "Compression quality (1-100)".to_string(),
517 param_type: ParameterType::Number,
518 default: Some(serde_json::json!(80)),
519 constraints: Some(Constraints {
520 min: Some(1.0),
521 max: Some(100.0),
522 required: false,
523 }),
524 ..Default::default()
525 }],
526 input_cardinality: InputCardinality::PerFile,
527 requires: vec![],
528 };
529
530 let json = serde_json::to_string_pretty(&metadata).unwrap();
531 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
532
533 assert_eq!(parsed["nodeType"], "image-compress");
534 assert_eq!(parsed["category"], "image");
535 assert_eq!(parsed["platforms"][0], "browser");
536 assert_eq!(parsed["accepts"].as_array().unwrap().len(), 3);
537 assert_eq!(parsed["parameters"].as_array().unwrap().len(), 1);
538 assert_eq!(parsed["parameters"][0]["name"], "quality");
539 assert_eq!(parsed["parameters"][0]["default"], 80);
540 }
541
542 #[test]
545 fn test_param_condition_single_serializes_as_object() {
546 let condition = ParamCondition::Single(ParamConditionEntry {
547 param: "operation".to_string(),
548 equals: "resize".to_string(),
549 });
550 let json = serde_json::to_string(&condition).unwrap();
551 assert_eq!(json, r#"{"param":"operation","equals":"resize"}"#);
552 }
553
554 #[test]
555 fn test_param_condition_any_serializes_as_array() {
556 let condition = ParamCondition::Any(vec![
557 ParamConditionEntry {
558 param: "operation".to_string(),
559 equals: "resize".to_string(),
560 },
561 ParamConditionEntry {
562 param: "operation".to_string(),
563 equals: "crop".to_string(),
564 },
565 ]);
566 let json = serde_json::to_string(&condition).unwrap();
567 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
568 assert!(parsed.is_array(), "Any condition should be a JSON array");
569 assert_eq!(parsed.as_array().unwrap().len(), 2);
570 assert_eq!(parsed[0]["param"], "operation");
571 assert_eq!(parsed[0]["equals"], "resize");
572 assert_eq!(parsed[1]["equals"], "crop");
573 }
574
575 #[test]
576 fn test_parameter_def_with_ui_fields_serializes_camel_case() {
577 let param = ParameterDef {
578 name: "width".to_string(),
579 label: "Width".to_string(),
580 description: "Target width in pixels".to_string(),
581 param_type: ParameterType::Number,
582 default: None,
583 constraints: None,
584 placeholder: Some("e.g. 800".to_string()),
585 visible_when: Some(ParamCondition::Single(ParamConditionEntry {
586 param: "operation".to_string(),
587 equals: "resize".to_string(),
588 })),
589 ..Default::default()
590 };
591 let json = serde_json::to_string(¶m).unwrap();
592 assert!(json.contains(r#""visibleWhen""#));
593 assert!(!json.contains("visible_when"));
594 assert!(json.contains(r#""placeholder":"e.g. 800""#));
595 assert!(!json.contains("requiredWhen"));
596 }
597
598 #[test]
601 fn test_dependency_serializes_camel_case() {
602 let dep = Dependency {
603 binary: "yt-dlp".to_string(),
604 version: ">=2023.01.01".to_string(),
605 install_hint: "brew install yt-dlp".to_string(),
606 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
607 };
608 let json = serde_json::to_string(&dep).unwrap();
609 assert!(json.contains(r#""binary":"yt-dlp""#));
610 assert!(json.contains(r#""version":">=2023.01.01""#));
611 assert!(json.contains(r#""installHint":"brew install yt-dlp""#));
612 assert!(json.contains(r#""homepage":"https://github.com/yt-dlp/yt-dlp""#));
613 assert!(!json.contains("install_hint"));
614 }
615
616 #[test]
617 fn test_dependency_skips_empty_optional_fields() {
618 let dep = Dependency {
619 binary: "ffmpeg".to_string(),
620 version: String::new(),
621 install_hint: "brew install ffmpeg".to_string(),
622 homepage: String::new(),
623 };
624 let json = serde_json::to_string(&dep).unwrap();
625 assert!(!json.contains("version"), "empty version should be omitted");
626 assert!(
627 !json.contains("homepage"),
628 "empty homepage should be omitted"
629 );
630 assert!(json.contains(r#""binary":"ffmpeg""#));
631 assert!(json.contains(r#""installHint""#));
632 }
633
634 #[test]
635 fn test_dependency_equality() {
636 let a = Dependency {
637 binary: "yt-dlp".to_string(),
638 version: ">=2023.01.01".to_string(),
639 install_hint: "brew install yt-dlp".to_string(),
640 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
641 };
642 let b = a.clone();
643 assert_eq!(a, b);
644 }
645
646 #[test]
647 fn test_metadata_requires_empty_skipped_in_serialization() {
648 let metadata = NodeMetadata {
649 node_type: "image-compress".to_string(),
650 name: "Compress".to_string(),
651 description: String::new(),
652 category: NodeCategory::Image,
653 accepts: vec![],
654 platforms: vec!["browser".to_string()],
655 parameters: vec![],
656 input_cardinality: InputCardinality::PerFile,
657 requires: vec![],
658 };
659 let json = serde_json::to_string(&metadata).unwrap();
660 assert!(
661 !json.contains("requires"),
662 "empty requires should be omitted"
663 );
664 }
665
666 #[test]
667 fn test_metadata_requires_present_when_populated() {
668 let metadata = NodeMetadata {
669 node_type: "video-download".to_string(),
670 name: "Download Video".to_string(),
671 description: String::new(),
672 category: NodeCategory::Network,
673 accepts: vec![],
674 platforms: vec!["server".to_string()],
675 parameters: vec![],
676 input_cardinality: InputCardinality::PerFile,
677 requires: vec![Dependency {
678 binary: "yt-dlp".to_string(),
679 version: ">=2023.01.01".to_string(),
680 install_hint: "brew install yt-dlp".to_string(),
681 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
682 }],
683 };
684 let json = serde_json::to_string(&metadata).unwrap();
685 assert!(json.contains(r#""requires""#));
686 assert!(json.contains(r#""binary":"yt-dlp""#));
687 assert!(json.contains(r#""version":">=2023.01.01""#));
688 }
689
690 #[test]
693 fn test_preset_entry_serializes_value_and_label() {
694 let preset = PresetEntry {
695 value: serde_json::json!(80),
696 label: "Balanced".to_string(),
697 };
698 let json = serde_json::to_string(&preset).unwrap();
699 assert!(json.contains(r#""value":80"#));
700 assert!(json.contains(r#""label":"Balanced""#));
701 }
702
703 #[test]
704 fn test_preset_entry_accepts_heterogeneous_values() {
705 let string_preset = PresetEntry {
706 value: serde_json::json!("jpeg"),
707 label: "JPEG".to_string(),
708 };
709 let json = serde_json::to_string(&string_preset).unwrap();
710 assert!(json.contains(r#""value":"jpeg""#));
711 }
712
713 #[test]
714 fn test_option_entry_serializes_value_and_label() {
715 let option = OptionEntry {
716 value: "snake".to_string(),
717 label: "snake_case".to_string(),
718 };
719 let json = serde_json::to_string(&option).unwrap();
720 assert_eq!(json, r#"{"value":"snake","label":"snake_case"}"#);
721 }
722
723 #[test]
724 fn test_parameter_def_presets_round_trip() {
725 let param = ParameterDef {
726 name: "quality".to_string(),
727 label: "Quality".to_string(),
728 description: "Compression quality".to_string(),
729 param_type: ParameterType::Number,
730 presets: Some(vec![
731 PresetEntry {
732 value: serde_json::json!(60),
733 label: "Draft".to_string(),
734 },
735 PresetEntry {
736 value: serde_json::json!(80),
737 label: "Balanced".to_string(),
738 },
739 PresetEntry {
740 value: serde_json::json!(100),
741 label: "Maximum".to_string(),
742 },
743 ]),
744 ..Default::default()
745 };
746 let json = serde_json::to_string(¶m).unwrap();
747 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
748 let presets = parsed["presets"].as_array().unwrap();
749 assert_eq!(presets.len(), 3);
750 assert_eq!(presets[0]["value"], 60);
751 assert_eq!(presets[0]["label"], "Draft");
752 assert_eq!(presets[1]["label"], "Balanced");
753 assert_eq!(presets[2]["value"], 100);
754 }
755
756 #[test]
757 fn test_parameter_def_group_and_suffix_round_trip() {
758 let param = ParameterDef {
759 name: "width".to_string(),
760 label: "Width".to_string(),
761 description: "Target width".to_string(),
762 param_type: ParameterType::Number,
763 group: Some("dimensions".to_string()),
764 suffix: Some("px".to_string()),
765 ..Default::default()
766 };
767 let json = serde_json::to_string(¶m).unwrap();
768 assert!(json.contains(r#""group":"dimensions""#));
769 assert!(json.contains(r#""suffix":"px""#));
770 }
771
772 #[test]
773 fn test_parameter_def_control_and_accept_round_trip() {
774 let param = ParameterDef {
775 name: "image".to_string(),
776 label: "Watermark image".to_string(),
777 description: "Image to overlay".to_string(),
778 param_type: ParameterType::String,
779 control: Some("file".to_string()),
780 accept: Some(vec!["image/*".to_string()]),
781 ..Default::default()
782 };
783 let json = serde_json::to_string(¶m).unwrap();
784 assert!(json.contains(r#""control":"file""#));
785 assert!(json.contains(r#""accept":["image/*"]"#));
786 }
787
788 #[test]
789 fn test_parameter_def_control_without_accept() {
790 let param = ParameterDef {
791 name: "preview".to_string(),
792 label: "Preview".to_string(),
793 description: "Watermark preview".to_string(),
794 param_type: ParameterType::String,
795 control: Some("watermarkPreview".to_string()),
796 ..Default::default()
797 };
798 let json = serde_json::to_string(¶m).unwrap();
799 assert!(json.contains(r#""control":"watermarkPreview""#));
800 assert!(!json.contains("accept"));
801 }
802
803 #[test]
804 fn test_parameter_def_inverted_round_trip() {
805 let param = ParameterDef {
806 name: "stripExif".to_string(),
807 label: "Keep metadata".to_string(),
808 description: "Preserve EXIF metadata".to_string(),
809 param_type: ParameterType::Boolean,
810 inverted: Some(true),
811 ..Default::default()
812 };
813 let json = serde_json::to_string(¶m).unwrap();
814 assert!(json.contains(r#""inverted":true"#));
815 }
816
817 #[test]
818 fn test_parameter_def_new_fields_skip_none() {
819 let param = ParameterDef {
820 name: "quality".to_string(),
821 label: "Quality".to_string(),
822 description: "Compression quality".to_string(),
823 param_type: ParameterType::Number,
824 ..Default::default()
825 };
826 let json = serde_json::to_string(¶m).unwrap();
827 assert!(!json.contains("\"group\""));
828 assert!(!json.contains("\"suffix\""));
829 assert!(!json.contains("\"control\""));
830 assert!(!json.contains("\"accept\""));
831 assert!(!json.contains("\"presets\""));
832 assert!(!json.contains("\"inverted\""));
833 }
834
835 #[test]
836 fn test_parameter_def_default_new_fields_are_none() {
837 let param = ParameterDef::default();
838 assert!(param.group.is_none());
839 assert!(param.suffix.is_none());
840 assert!(param.control.is_none());
841 assert!(param.accept.is_none());
842 assert!(param.presets.is_none());
843 assert!(param.inverted.is_none());
844 }
845
846 #[test]
847 fn test_parameter_def_new_fields_use_camel_case() {
848 let param = ParameterDef {
849 name: "image".to_string(),
850 label: "Image".to_string(),
851 description: "Overlay image".to_string(),
852 param_type: ParameterType::String,
853 control: Some("file".to_string()),
854 accept: Some(vec!["image/png".to_string()]),
855 group: Some("media".to_string()),
856 suffix: Some("%".to_string()),
857 inverted: Some(false),
858 presets: Some(vec![PresetEntry {
859 value: serde_json::json!(80),
860 label: "Balanced".to_string(),
861 }]),
862 ..Default::default()
863 };
864 let json = serde_json::to_string(¶m).unwrap();
865 for key in [
866 "control", "accept", "group", "suffix", "inverted", "presets",
867 ] {
868 let needle = format!(r#""{key}""#);
869 assert!(
870 json.contains(&needle),
871 "expected serialized param to contain {needle}; got: {json}"
872 );
873 }
874 }
875}