1use adam_lang::ParsedSheet;
4use adam_rs::{CellId, FilterViolation, Sheet};
5use dioxus::prelude::*;
6
7use crate::labels::{Labels, Renderer, format_adam_error, format_rounded};
8use crate::spectrum::{
9 SpCheckbox, SpDivider, SpFieldLabel, SpHeading, SpHelpText, SpNumberfield, SpSlider,
10 SpTextfield,
11};
12
13use std::collections::{HashMap, HashSet};
14use std::hash::{Hash, Hasher};
15
16#[derive(Clone, PartialEq)]
20struct OutputStatus {
21 has_outputs: bool,
23 relevant: HashSet<CellId>,
42 invalid_contributors: HashSet<CellId>,
51 invalid_outputs: HashSet<CellId>,
53 filter_violated: HashSet<CellId>,
57 output_cells: HashSet<CellId>,
64 invalid_output_requirement_names: HashMap<CellId, String>,
69}
70
71fn compute_output_status(parsed: &ParsedSheet) -> OutputStatus {
78 let sheet = &parsed.sheet;
79 let out_cells: Vec<CellId> = sheet.out_cells().collect();
83 let relevant = out_cells
84 .iter()
85 .flat_map(|&id| sheet.contributing_cells(id))
86 .chain(sheet.requirement_relevant_cells())
87 .chain(
88 sheet
89 .conditionals()
90 .filter_map(|id| sheet.conditional_match_cells(id))
91 .flatten()
92 .copied(),
93 )
94 .collect();
95 let invalid_outputs = out_cells
96 .iter()
97 .copied()
98 .filter(|&id| !sheet.cell_requirements_valid(id))
99 .collect();
100 let invalid_contributors = sheet
101 .requirement_violation_cells()
102 .into_iter()
103 .chain(sheet.filter_violation_cells())
104 .collect();
105 let filter_violated = sheet.filter_violated_cells().collect();
106 let output_cells = out_cells.iter().copied().collect();
107 let invalid_output_requirement_names = out_cells
108 .iter()
109 .filter_map(|&cell| {
110 let names: Vec<&str> = sheet
111 .violated_requirements(cell)
112 .filter_map(|rid| parsed.requirement_names.get(&rid).map(String::as_str))
113 .collect();
114 (!names.is_empty()).then(|| (cell, names.join(", ")))
115 })
116 .collect();
117 OutputStatus {
118 has_outputs: !out_cells.is_empty(),
119 relevant,
120 invalid_contributors,
121 invalid_outputs,
122 filter_violated,
123 output_cells,
124 invalid_output_requirement_names,
125 }
126}
127
128fn format_filter_violation(label: &str, violation: &FilterViolation) -> String {
131 match violation {
132 FilterViolation::NotConformed => {
133 format!("filter violation: `{label}`'s derived value does not conform to its filter")
134 }
135 FilterViolation::Failed(e) => {
136 format!("filter violation: `{label}`'s filter failed on its derived value: {e}")
137 }
138 }
139}
140
141fn slider_invalid_message(
156 id: CellId,
157 label: &str,
158 sheet: &Sheet,
159 status: &OutputStatus,
160) -> Option<String> {
161 if let Some(names) = status.invalid_output_requirement_names.get(&id) {
162 return Some(names.clone());
163 }
164 if let Some(violation) = sheet.filter_violation(id) {
165 return Some(format_filter_violation(label, violation));
166 }
167 if status.invalid_outputs.contains(&id) || status.invalid_contributors.contains(&id) {
168 return Some(format!("`{label}` is invalid"));
169 }
170 None
171}
172
173#[derive(Clone, Copy, PartialEq, Eq)]
176struct CellFlags {
177 disabled: bool,
178 invalid: bool,
179 readonly: bool,
183}
184
185fn cell_flags(id: CellId, forced: bool, has_error: bool, status: &OutputStatus) -> CellFlags {
188 let disabled = forced || (status.has_outputs && !status.relevant.contains(&id));
189 let invalid = has_error
190 || status.invalid_outputs.contains(&id)
191 || status.filter_violated.contains(&id)
192 || status.invalid_contributors.contains(&id);
193 let readonly = status.output_cells.contains(&id);
194 CellFlags {
195 disabled,
196 invalid,
197 readonly,
198 }
199}
200
201fn toggled_bool_value(current: &str) -> &'static str {
203 if current == "true" { "false" } else { "true" }
204}
205
206fn dom_id_namespace(source_name: &str) -> u64 {
222 let mut hasher = std::collections::hash_map::DefaultHasher::new();
223 source_name.hash(&mut hasher);
224 hasher.finish()
225}
226
227fn slider_bounds(current: &str, range: (f64, f64)) -> (String, String) {
244 let (lo, hi) = range;
245 let current = current.parse::<f64>().unwrap_or(lo);
246 (lo.min(current).to_string(), hi.max(current).to_string())
247}
248
249fn numeric_format_options(is_integer: bool) -> Option<&'static str> {
252 if is_integer {
253 None
254 } else {
255 Some(r#"{"minimumFractionDigits":2,"maximumFractionDigits":2}"#)
256 }
257}
258
259fn slider_step(is_integer: bool) -> Option<&'static str> {
263 Some(if is_integer { "1" } else { "0.01" })
264}
265
266fn number_field_value(input: &str) -> &str {
268 if input.is_empty() { "NaN" } else { input }
269}
270
271fn slider_field_value<'a>(input: &'a str, computed: &'a str) -> &'a str {
273 if input.is_empty() { computed } else { input }
274}
275
276fn sync_input(is_numeric: bool, input: &str, focused: bool, previous: &str, current: &str) -> bool {
281 if is_numeric && input.is_empty() {
282 previous != current
283 } else {
284 !focused
285 }
286}
287
288fn numeric_input_script(field_id: &str, slider: bool) -> String {
295 format!(
296 r#"
297 {bridge}
298 const host = document.getElementById({field_id:?});
299 dioxus.send(readNumericInput(host, {slider}, document.activeElement === host));
300 "#,
301 bridge = include_str!("numeric_input.js"),
302 )
303}
304
305fn clamped_away(typed: &str, actual: &str) -> bool {
315 match typed.parse::<f64>() {
316 Ok(v) => format_rounded(v) != actual,
317 Err(_) => false,
318 }
319}
320
321#[derive(Clone, Copy)]
325struct ErrorContext {
326 source_text: Memo<String>,
327 source_name: Memo<String>,
328 parsed: Signal<ParsedSheet>,
329}
330
331fn write_and_propagate(
341 mut parsed: Signal<ParsedSheet>,
342 labels: Signal<Labels>,
343 id: CellId,
344 val: &str,
345 mut has_error: Signal<bool>,
346 errors: ErrorContext,
347) {
348 let labels_r = labels.read();
349 let Some(meta) = labels_r.cells.get(&id) else {
350 return;
351 };
352 if meta.is_numeric && val.is_empty() {
353 has_error.set(false);
354 return;
355 }
356 let mut parsed_w = parsed.write();
357 let write_result = (meta.write_str)(&mut parsed_w, val);
358 drop(labels_r);
359 let propagate_result = match write_result {
360 Ok(()) => parsed_w.propagate(),
361 Err(e) => Err(e),
362 };
363 match propagate_result {
364 Ok(()) => {
365 let labels_r = labels.read();
366 let clamped = labels_r
367 .cells
368 .get(&id)
369 .is_some_and(|m| clamped_away(val, &(m.display)(&parsed_w)));
370 has_error.set(clamped);
371 for violated_id in parsed_w.filter_violated_cells().collect::<Vec<_>>() {
372 let Some(violation) = parsed_w.filter_violation(violated_id) else {
373 continue;
374 };
375 let label = labels_r
376 .cells
377 .get(&violated_id)
378 .map(|m| m.label.as_str())
379 .unwrap_or("<unknown cell>");
380 crate::diagnostics::report_error(&format_filter_violation(label, violation));
381 }
382 }
383 Err(e) => {
384 drop(parsed_w);
389 has_error.set(true);
390 crate::diagnostics::report_error(&format_adam_error(
391 &e,
392 &errors.parsed.read(),
393 &errors.source_text.read(),
394 &errors.source_name.read(),
395 &Renderer::styled(),
396 ));
397 }
398 }
399}
400
401#[component]
412pub fn SheetInspector(
413 parsed: Signal<ParsedSheet>,
414 labels: Signal<Labels>,
415 source_text: Memo<String>,
416 source_name: Memo<String>,
417) -> Element {
418 let ids: Vec<CellId> = labels.read().cells.keys().copied().collect();
419 let output_status = use_memo(move || compute_output_status(&parsed.read()));
420
421 rsx! {
422 div {
423 style: "width: 260px; min-width: 260px; height: 100%; overflow-y: auto; padding: 12px; box-sizing: border-box;",
424 SpHeading { "Cells" }
425 SpDivider {}
426 for id in ids {
427 CellRow { key: "{id:?}", id, parsed, labels, source_text, source_name, output_status }
428 }
429 }
430 }
431}
432
433#[component]
434fn CellRow(
435 id: CellId,
436 parsed: Signal<ParsedSheet>,
437 labels: Signal<Labels>,
438 source_text: Memo<String>,
439 source_name: Memo<String>,
440 output_status: Memo<OutputStatus>,
441) -> Element {
442 let label = use_memo(move || {
443 labels
444 .read()
445 .cells
446 .get(&id)
447 .map(|m| m.label.clone())
448 .unwrap_or_default()
449 });
450
451 let value = use_memo(move || {
452 let s = parsed.read();
453 let l = labels.read();
454 l.cells
455 .get(&id)
456 .map(|m| (m.display)(&s))
457 .unwrap_or_default()
458 });
459
460 let is_bool = use_memo(move || {
461 labels
462 .read()
463 .cells
464 .get(&id)
465 .map(|m| m.is_bool)
466 .unwrap_or(false)
467 });
468
469 let is_numeric = use_memo(move || {
470 labels
471 .read()
472 .cells
473 .get(&id)
474 .map(|m| m.is_numeric)
475 .unwrap_or(false)
476 });
477
478 let range = use_memo(move || {
479 labels
480 .read()
481 .cells
482 .get(&id)
483 .and_then(|m| m.range.as_ref())
484 .map(|f| f(&parsed.read()))
485 });
486
487 let is_integer = use_memo(move || {
488 labels
489 .read()
490 .cells
491 .get(&id)
492 .map(|m| m.is_integer)
493 .unwrap_or(false)
494 });
495
496 let forced = use_memo(move || parsed.read().is_forced(id));
497
498 let mut input = use_signal(|| value.peek().clone());
499 let mut previous_value = use_signal(|| value.peek().clone());
500 let mut is_focused = use_signal(|| false);
501 let mut has_error = use_signal(|| false);
502
503 let errors = ErrorContext {
504 source_text,
505 source_name,
506 parsed,
507 };
508
509 let flags =
510 use_memo(move || cell_flags(id, *forced.read(), *has_error.read(), &output_status.read()));
511
512 let violated_requirement_names = use_memo(move || {
516 output_status
517 .read()
518 .invalid_output_requirement_names
519 .get(&id)
520 .cloned()
521 });
522
523 let slider_message = use_memo(move || {
527 slider_invalid_message(id, &label.read(), &parsed.read(), &output_status.read())
528 });
529
530 use_effect(move || {
533 let v = value.read().clone();
534 if sync_input(
535 *is_numeric.read(),
536 &input.peek(),
537 *is_focused.read(),
538 &previous_value.peek(),
539 &v,
540 ) {
541 input.set(v);
542 }
543 previous_value.set(value.peek().clone());
544 });
545
546 let field_id = format!("cell-{}-{id:?}", dom_id_namespace(&source_name.read()));
547
548 rsx! {
549 div {
550 style: "margin-bottom: 8px;",
551 if let Some((lo, hi)) = *range.read() {
552 {
553 let slider_value = slider_field_value(&input.read(), &value.read()).to_string();
554 let (min, max) = slider_bounds(&slider_value, (lo, hi));
555 let step = slider_step(*is_integer.read()).map(str::to_string);
556 rsx! {
557 SpSlider {
558 id: field_id,
559 label: label.read().clone(),
560 value: slider_value,
561 min,
562 max,
563 step,
564 format_options: numeric_format_options(*is_integer.read()),
565 editable: true,
566 disabled: flags.read().disabled,
567 oninput: move |_: FormEvent| {
568 let ns = dom_id_namespace(&source_name.read());
569 spawn(async move {
570 let mut eval = document::eval(&numeric_input_script(
573 &format!("cell-{ns}-{id:?}"),
574 true,
575 ));
576 let Ok(payload) = eval.recv::<String>().await else { return; };
577 let Some((focused, val)) = payload.split_once('|') else { return; };
578 is_focused.set(focused == "1");
579 input.set(val.to_string());
580 write_and_propagate(parsed, labels, id, val, has_error, errors);
581 });
582 },
583 onfocus: move |_| is_focused.set(true),
584 onblur: move |_| {
585 is_focused.set(false);
586 has_error.set(false);
587 },
588 }
589 if let Some(msg) = slider_message.read().clone() {
602 SpHelpText { slot: "negative-help-text".to_string(), variant: "negative".to_string(), "{msg}" }
603 }
604 }
605 }
606 } else if *is_bool.read() {
607 SpCheckbox {
612 id: field_id,
613 checked: *value.read() == "true",
614 invalid: flags.read().invalid,
615 disabled: flags.read().disabled,
616 onclick: move |_| {
617 let next = toggled_bool_value(&value.peek());
618 write_and_propagate(parsed, labels, id, next, has_error, errors);
619 let checked = *value.read() == "true";
627 let ns = dom_id_namespace(&source_name.read());
628 spawn(async move {
629 let _ = document::eval(&format!(
630 r#"document.getElementById("cell-{ns}-{id:?}").checked = {checked};"#
631 ))
632 .await;
633 });
634 },
635 "{label}"
636 }
637 } else {
638 SpFieldLabel { for_: field_id.clone(), "{label}" }
639 if *is_numeric.read() {
640 SpNumberfield {
641 id: field_id,
642 value: number_field_value(&input.read()).to_string(),
643 min: None,
644 max: None,
645 step: is_integer.read().then(|| "1".to_string()),
646 format_options: numeric_format_options(*is_integer.read()),
647 invalid: flags.read().invalid,
648 disabled: flags.read().disabled && !flags.read().readonly,
654 readonly: flags.read().readonly,
655 oninput: move |_: FormEvent| {
656 let ns = dom_id_namespace(&source_name.read());
657 spawn(async move {
658 let mut eval = document::eval(&numeric_input_script(
693 &format!("cell-{ns}-{id:?}"),
694 false,
695 ));
696 let Ok(payload) = eval.recv::<String>().await else { return; };
697 let Some((still_focused, val)) = payload.split_once('|') else { return; };
698 if still_focused != "1" {
699 return;
700 }
701 is_focused.set(true);
702 input.set(val.to_string());
703 write_and_propagate(parsed, labels, id, val, has_error, errors);
704 });
705 },
706 onfocus: move |_| is_focused.set(true),
707 onblur: move |_| {
708 is_focused.set(false);
709 has_error.set(false);
710 },
711 if let Some(names) = violated_requirement_names.read().clone() {
725 SpHelpText { slot: "negative-help-text".to_string(), variant: "negative".to_string(), "{names}" }
726 }
727 }
728 } else {
729 SpTextfield {
730 id: field_id,
731 value: input.read().clone(),
732 invalid: flags.read().invalid,
733 disabled: flags.read().disabled,
734 oninput: move |_: FormEvent| {
738 let ns = dom_id_namespace(&source_name.read());
739 spawn(async move {
740 let mut eval = document::eval(&format!(
741 r#"dioxus.send(document.getElementById("cell-{ns}-{id:?}").value)"#
742 ));
743 let Ok(val) = eval.recv::<String>().await else { return; };
744 if !*is_focused.read() {
748 return;
749 }
750 input.set(val.clone());
751 write_and_propagate(parsed, labels, id, &val, has_error, errors);
752 });
753 },
754 onfocus: move |_| is_focused.set(true),
755 onblur: move |_| {
756 is_focused.set(false);
757 has_error.set(false);
758 },
759 }
760 }
761 }
762 }
763 SpDivider {}
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
773 fn sync_input_preserves_blank_numeric_drafts() {
774 for focused in [false, true] {
775 assert!(!sync_input(true, "", focused, "51", "51"));
776 assert!(sync_input(true, "", focused, "51", "10"));
777 }
778 }
779
780 #[test]
782 fn sync_input_preserves_existing_editing_behavior() {
783 for numeric in [false, true] {
784 assert!(!sync_input(numeric, "1.", true, "1", "2"));
785 assert!(sync_input(numeric, "1.", false, "1", "2"));
786 }
787 assert!(sync_input(false, "", false, "old", "old"));
788 assert!(!sync_input(false, "", true, "old", "new"));
789 }
790
791 #[test]
793 fn number_field_value_preserves_empty_and_nonempty_drafts() {
794 assert_eq!(number_field_value(""), "NaN");
795 for value in ["0", "5", "-1.5", "1."] {
796 assert_eq!(number_field_value(value), value);
797 }
798 }
799
800 #[test]
802 fn slider_field_value_preserves_computed_value_for_empty_drafts() {
803 assert_eq!(slider_field_value("", "51"), "51");
804 assert_eq!(
805 slider_bounds(slider_field_value("", "51"), (0.0, 10.0)),
806 ("0".to_string(), "51".to_string())
807 );
808 assert_eq!(slider_field_value("5", "51"), "5");
809 }
810
811 thread_local! {
812 static EMPTY_WRITES_CHECKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
814 }
815
816 fn empty_numeric_write_app() -> Element {
820 let source = r#"sheet s { cell float: f64 = 51.0; cell integer: i32 = 12; cell text: String = "old"; }"#;
821 let sheet = parse(source);
822 let metadata = crate::labels::labels_from_cell_names(&sheet, &sheet.cell_names);
823 let parsed = use_signal(move || sheet);
824 let labels = use_signal(move || metadata);
825 let mut has_error = use_signal(|| true);
826 let source_text = use_memo(move || source.to_string());
827 let source_name = use_memo(|| "empty-edit.adm2".to_string());
828 use_hook(move || {
829 for name in ["float", "integer", "text"] {
830 has_error.set(true);
831 let id = parsed.read().cell_names[name].0;
832 write_and_propagate(
833 parsed,
834 labels,
835 id,
836 "",
837 has_error,
838 ErrorContext {
839 source_text,
840 source_name,
841 parsed,
842 },
843 );
844 assert!(
845 !*has_error.read(),
846 "empty {name} edit must not report a parse error"
847 );
848 }
849 let sheet = parsed.read();
850 assert_eq!(
851 *sheet.read::<f64>(sheet.cell_names["float"].0).unwrap(),
852 51.0
853 );
854 assert_eq!(
855 *sheet.read::<i32>(sheet.cell_names["integer"].0).unwrap(),
856 12
857 );
858 assert_eq!(
859 sheet.read::<String>(sheet.cell_names["text"].0).unwrap(),
860 ""
861 );
862 EMPTY_WRITES_CHECKED.set(true);
863 });
864 rsx! { div {} }
865 }
866
867 #[test]
869 fn empty_numeric_writes_preserve_sheet_values() {
870 EMPTY_WRITES_CHECKED.set(false);
871 VirtualDom::new(empty_numeric_write_app).rebuild_in_place();
872 assert!(
873 EMPTY_WRITES_CHECKED.get(),
874 "live-write fixture must complete every assertion"
875 );
876 }
877
878 #[test]
880 fn slider_step_configures_hundredths_for_floats() {
881 let parsed = parse("sheet s { cell a: f32 = 3.0_f32; cell b: f64 = 25.5; }");
882 let labels = crate::labels::labels_from_cell_names(&parsed, &parsed.cell_names);
883 assert_eq!(labels.cells.len(), 2);
884 for meta in labels.cells.values() {
885 assert_eq!(slider_step(meta.is_integer), Some("0.01"));
886 }
887 }
888
889 #[test]
891 fn slider_step_preserves_whole_numbers_for_integers() {
892 let parsed = parse("sheet s { cell a: i32 = 51; cell b: u32 = 26_u32; }");
893 let labels = crate::labels::labels_from_cell_names(&parsed, &parsed.cell_names);
894 assert_eq!(labels.cells.len(), 2);
895 for meta in labels.cells.values() {
896 assert_eq!(slider_step(meta.is_integer), Some("1"));
897 }
898 }
899
900 #[test]
902 fn numeric_format_options_sets_two_fractional_digits_for_floats() {
903 let parsed = parse("sheet s { cell a: f32 = 3.0_f32; cell b: f64 = 1.234; }");
904 let labels = crate::labels::labels_from_cell_names(&parsed, &parsed.cell_names);
905 assert_eq!(labels.cells.len(), 2);
906 for meta in labels.cells.values() {
907 let options: serde_json::Value =
908 serde_json::from_str(numeric_format_options(meta.is_integer).unwrap()).unwrap();
909 assert_eq!(options["minimumFractionDigits"], 2);
910 assert_eq!(options["maximumFractionDigits"], 2);
911 }
912 }
913
914 #[test]
916 fn numeric_format_options_preserves_integer_defaults() {
917 assert_eq!(numeric_format_options(true), None);
918 }
919
920 #[test]
922 fn composed_example_lock_preserves_aspect_ratio() {
923 let mut parsed = parse(include_str!("../../adam-slides/examples/three_way.adm2"));
924 let lock = parsed.cell_names["lock"].0;
925 let width = parsed.cell_names["width_inches"].0;
926 let height = parsed.cell_names["height_inches"].0;
927 parsed.propagate().unwrap();
928 parsed.write(lock, true).unwrap();
929 parsed.propagate().unwrap();
930 assert!((*parsed.read::<f64>(width).unwrap() - 3.0).abs() < 1e-10);
931 assert!((*parsed.read::<f64>(height).unwrap() - 5.0).abs() < 1e-10);
932 parsed.write(width, 6.0_f64).unwrap();
933 parsed.propagate().unwrap();
934 assert!((*parsed.read::<f64>(height).unwrap() - 10.0).abs() < 1e-10);
935 parsed.write(height, 15.0_f64).unwrap();
936 parsed.propagate().unwrap();
937 assert!((*parsed.read::<f64>(width).unwrap() - 9.0).abs() < 1e-10);
938 }
939
940 fn parse(source: &str) -> ParsedSheet {
944 adam_lang::AdamParser::new(adam_lang::TypeRegistry::new(), cel_parser::OpLookup::new())
945 .parse_str(source)
946 .unwrap()
947 }
948
949 fn parsed_with_sheet(sheet: Sheet) -> ParsedSheet {
951 let mut parsed = parse("sheet s {}");
952 parsed.sheet = sheet;
953 parsed
954 }
955
956 fn live_write_fixture(source: &str, mismatch: bool) -> (ParsedSheet, Labels, CellId) {
964 use adam_rs::Method;
965 use std::any::TypeId;
966
967 let mut parsed = parse(source);
968 debug_assert_eq!(parsed.cell_names.len(), 2);
969 debug_assert_eq!(parsed.method_spans.len(), 1);
970 let mut sheet = Sheet::new();
971 let input = sheet.add_cell(0_i32);
972 let output = sheet.add_cell(0_i32);
973 assert_eq!(input, parsed.cell_names[0].0);
974 assert_eq!(output, parsed.cell_names[1].0);
975 let relationship = sheet
976 .add_relationship(vec![Method::new(
977 vec![input],
978 vec![output],
979 vec![TypeId::of::<i32>()],
980 vec![TypeId::of::<i32>()],
981 move |_| {
982 if mismatch {
983 Ok(vec![Box::new(true)])
984 } else {
985 Err(anyhow::anyhow!("live method failure"))
986 }
987 },
988 )])
989 .unwrap();
990 assert!(parsed.method_spans.contains_key(&(relationship, 0)));
991 parsed.sheet = sheet;
992 let mut labels = Labels::new();
993 labels.add_cell::<i32>(input, parsed.cell_names.get_index(0).unwrap().0);
994 (parsed, labels, input)
995 }
996
997 fn live_write_app() -> Element {
1001 let mismatch = std::env::var("ADAM_WEB_UI_LIVE_WRITE_CHILD").unwrap() == "type";
1002 let original = "sheet s {\ncell trigger: i32;\ncell failed: i32;\nrelationship {\nfailed := trigger;\n}\n}";
1003 let replacement = "sheet replacement {\n\ncell edited: i32;\ncell fresh: i32;\n\nrelationship {\nfresh := edited;\n}\n}";
1004 let (initial_parsed, initial_labels, _) = live_write_fixture(original, mismatch);
1005 let mut parsed = use_signal(move || initial_parsed);
1006 let mut labels = use_signal(move || initial_labels);
1007 let has_error = use_signal(|| false);
1008 let source_text = use_memo(move || original.to_string());
1009 let source_name = use_memo(|| "original.adm2".to_string());
1010 let fresh_text = use_memo(move || replacement.to_string());
1011 let fresh_name = use_memo(|| "replacement.adm2".to_string());
1012 use_hook(move || {
1013 let id = parsed.read().cell_names["trigger"].0;
1014 write_and_propagate(
1015 parsed,
1016 labels,
1017 id,
1018 "7",
1019 has_error,
1020 ErrorContext {
1021 source_text,
1022 source_name,
1023 parsed,
1024 },
1025 );
1026 assert!(*has_error.read());
1027 assert_eq!(*parsed.read().read::<i32>(id).unwrap(), 7);
1028 let (fresh, fresh_labels, id) = live_write_fixture(replacement, mismatch);
1029 parsed.set(fresh);
1030 labels.set(fresh_labels);
1031 write_and_propagate(
1032 parsed,
1033 labels,
1034 id,
1035 "9",
1036 has_error,
1037 ErrorContext {
1038 source_text: fresh_text,
1039 source_name: fresh_name,
1040 parsed,
1041 },
1042 );
1043 assert!(*has_error.read());
1044 assert_eq!(*parsed.read().read::<i32>(id).unwrap(), 9);
1045 });
1046 rsx! { div {} }
1047 }
1048
1049 #[test]
1050 fn live_write_errors_use_current_parsed_sheet_spans() {
1052 if std::env::var_os("ADAM_WEB_UI_LIVE_WRITE_CHILD").is_some() {
1053 let mut dom = VirtualDom::new(live_write_app);
1054 dom.rebuild_in_place();
1055 return;
1056 }
1057 for (kind, error) in [("method", "live method failure"), ("type", "type mismatch")] {
1058 let output = std::process::Command::new(std::env::current_exe().unwrap())
1059 .args([
1060 "--exact",
1061 "inspector::tests::live_write_errors_use_current_parsed_sheet_spans",
1062 "--nocapture",
1063 ])
1064 .env("ADAM_WEB_UI_LIVE_WRITE_CHILD", kind)
1065 .output()
1066 .unwrap();
1067 let stderr = String::from_utf8(output.stderr).unwrap();
1068 assert!(output.status.success(), "{kind}: {stderr}");
1069 for expected in [
1070 error,
1071 "original.adm2:5",
1072 "failed := trigger;",
1073 "replacement.adm2:7",
1074 "fresh := edited;",
1075 ] {
1076 assert!(stderr.contains(expected), "missing {expected}: {stderr}");
1077 }
1078 }
1079 }
1080
1081 fn status(
1082 has_outputs: bool,
1083 relevant: &[CellId],
1084 invalid_contributors: &[CellId],
1085 invalid_outputs: &[CellId],
1086 ) -> OutputStatus {
1087 status_with_filter_violated(
1088 has_outputs,
1089 relevant,
1090 invalid_contributors,
1091 invalid_outputs,
1092 &[],
1093 )
1094 }
1095
1096 fn status_with_filter_violated(
1097 has_outputs: bool,
1098 relevant: &[CellId],
1099 invalid_contributors: &[CellId],
1100 invalid_outputs: &[CellId],
1101 filter_violated: &[CellId],
1102 ) -> OutputStatus {
1103 status_full(
1104 has_outputs,
1105 relevant,
1106 invalid_contributors,
1107 invalid_outputs,
1108 filter_violated,
1109 &[],
1110 &[],
1111 )
1112 }
1113
1114 fn status_full(
1115 has_outputs: bool,
1116 relevant: &[CellId],
1117 invalid_contributors: &[CellId],
1118 invalid_outputs: &[CellId],
1119 filter_violated: &[CellId],
1120 output_cells: &[CellId],
1121 invalid_output_requirement_names: &[(CellId, &str)],
1122 ) -> OutputStatus {
1123 OutputStatus {
1124 has_outputs,
1125 relevant: relevant.iter().copied().collect(),
1126 invalid_contributors: invalid_contributors.iter().copied().collect(),
1127 invalid_outputs: invalid_outputs.iter().copied().collect(),
1128 filter_violated: filter_violated.iter().copied().collect(),
1129 output_cells: output_cells.iter().copied().collect(),
1130 invalid_output_requirement_names: invalid_output_requirement_names
1131 .iter()
1132 .map(|&(id, name)| (id, name.to_string()))
1133 .collect(),
1134 }
1135 }
1136
1137 #[test]
1138 fn compute_output_status_filter_violated_includes_the_cell_whose_own_filter_failed() {
1139 use adam_rs::{Filter, Method};
1140
1141 let mut sheet = Sheet::new();
1142 let a = sheet.add_cell(0.0_f64);
1143 let b = sheet.add_cell(0.0_f64);
1144 sheet
1145 .add_filter(a, Filter::from_fn_0(|x: &f64| Ok(x.clamp(0.0, 100.0))))
1146 .unwrap();
1147 sheet
1148 .add_relationship(vec![Method::from_fn_1_1(b, a, |v: &f64| Ok(*v))])
1149 .unwrap();
1150 sheet.write(b, -30.0_f64).unwrap();
1151 sheet.propagate().unwrap();
1152
1153 let status = compute_output_status(&parsed_with_sheet(sheet));
1154 assert!(status.filter_violated.contains(&a));
1155 assert!(status.invalid_contributors.contains(&b));
1156 }
1157
1158 #[test]
1159 fn compute_output_status_filter_violated_empty_when_no_filter_is_violated() {
1160 use adam_rs::{Filter, Method};
1161
1162 let mut sheet = Sheet::new();
1163 let a = sheet.add_cell(0.0_f64);
1164 let b = sheet.add_cell(0.0_f64);
1165 sheet
1166 .add_filter(a, Filter::from_fn_0(|x: &f64| Ok(x.clamp(0.0, 100.0))))
1167 .unwrap();
1168 sheet
1169 .add_relationship(vec![Method::from_fn_1_1(b, a, |v: &f64| Ok(*v))])
1170 .unwrap();
1171 sheet.write(b, 30.0_f64).unwrap();
1172 sheet.propagate().unwrap();
1173
1174 let status = compute_output_status(&parsed_with_sheet(sheet));
1175 assert!(status.filter_violated.is_empty());
1176 assert!(status.invalid_contributors.is_empty());
1177 }
1178
1179 #[test]
1180 fn compute_output_status_output_cells_contains_the_outputs_own_cell() {
1181 use adam_rs::Method;
1182
1183 let mut sheet = Sheet::new();
1184 let a = sheet.add_cell(0_i32);
1185 let result = sheet.add_cell(0_i32);
1186 sheet
1187 .add_out(Method::from_fn_1_1(a, result, |x: &i32| Ok(*x)), vec![])
1188 .unwrap();
1189
1190 let status = compute_output_status(&parsed_with_sheet(sheet));
1191 assert!(status.output_cells.contains(&result));
1192 assert!(!status.output_cells.contains(&a));
1193 }
1194
1195 #[test]
1196 fn compute_output_status_output_cells_empty_when_no_outputs() {
1197 let sheet = Sheet::new();
1198 let status = compute_output_status(&parsed_with_sheet(sheet));
1199 assert!(status.output_cells.is_empty());
1200 }
1201
1202 #[test]
1203 fn compute_output_status_invalid_output_requirement_names_names_the_failing_requirement() {
1204 let mut parsed = parse(
1205 "sheet s { cell input: i32 = 2000; out area := input require { @not_too_big area <= 300i32; }; }",
1206 );
1207 parsed.propagate().unwrap();
1208 let area = parsed.output_names["area"];
1209 let status = compute_output_status(&parsed);
1210 assert_eq!(
1211 status.invalid_output_requirement_names.get(&area),
1212 Some(&"not_too_big".to_string())
1213 );
1214 }
1215
1216 #[test]
1217 fn compute_output_status_invalid_output_requirement_names_empty_when_requirement_holds() {
1218 let mut parsed = parse(
1219 "sheet s { cell input: i32 = 200; out area := input require { @not_too_big area <= 300i32; }; }",
1220 );
1221 parsed.propagate().unwrap();
1222 let status = compute_output_status(&parsed);
1223 assert!(status.invalid_output_requirement_names.is_empty());
1224 }
1225
1226 #[test]
1227 fn compute_output_status_invalid_output_requirement_names_joins_multiple_violated_names() {
1228 let mut parsed = parse(
1229 "sheet s { cell input: i32 = 101; out result := input require {
1230 @too_big result <= 10i32;
1231 result < 0i32;
1232 @passing result > 0i32;
1233 @not_even result % 2i32 == 0i32;
1234 }; }",
1235 );
1236 parsed.propagate().unwrap();
1237 let result = parsed.output_names["result"];
1238 let status = compute_output_status(&parsed);
1239 let names = status
1240 .invalid_output_requirement_names
1241 .get(&result)
1242 .expect("result should have violated requirements");
1243 assert_eq!(names, "too_big, not_even");
1244 }
1245
1246 #[test]
1247 fn compute_output_status_unnamed_failure_retains_generic_invalid_message() {
1249 let mut parsed = parse(
1250 "sheet s { cell input: i32 = 1; out result := input require { result < 0i32; result > 9i32; }; }",
1251 );
1252 parsed.propagate().unwrap();
1253 let result = parsed.output_names["result"];
1254 let status = compute_output_status(&parsed);
1255 assert!(status.invalid_outputs.contains(&result));
1256 assert!(
1257 !status
1258 .invalid_output_requirement_names
1259 .contains_key(&result)
1260 );
1261 assert_eq!(
1262 slider_invalid_message(result, "result", &parsed, &status),
1263 Some("`result` is invalid".to_string())
1264 );
1265 }
1266
1267 #[test]
1268 fn compute_output_status_covers_a_plain_cells_requirement_violation() {
1269 use adam_rs::Requirement;
1270
1271 let mut sheet = Sheet::new();
1276 let a = sheet.add_cell(200_i32);
1277 sheet
1278 .add_requirement(a, Requirement::from_fn_1(a, |x: &i32| Ok(*x > 100)))
1279 .unwrap();
1280 sheet.write(a, 5_i32).unwrap();
1281 sheet.propagate().unwrap();
1282
1283 let status = compute_output_status(&parsed_with_sheet(sheet));
1284 assert!(status.invalid_contributors.contains(&a));
1285 }
1286
1287 #[test]
1288 fn compute_output_status_relevant_includes_an_out_cells_contributor_even_without_requirements()
1289 {
1290 use adam_rs::Method;
1295
1296 let mut sheet = Sheet::new();
1297 let a = sheet.add_cell(5_i32);
1298 let result = sheet.add_cell(0_i32);
1299 sheet
1300 .add_out(Method::from_fn_1_1(a, result, |x: &i32| Ok(*x)), vec![])
1301 .unwrap();
1302 sheet.propagate().unwrap();
1303
1304 let status = compute_output_status(&parsed_with_sheet(sheet));
1305 assert!(status.relevant.contains(&a));
1306 }
1307
1308 #[test]
1309 fn format_filter_violation_not_conformed_names_the_cell() {
1310 let msg = format_filter_violation("a", &FilterViolation::NotConformed);
1311 assert!(msg.contains('a'));
1312 assert!(msg.contains("does not conform"));
1313 }
1314
1315 #[test]
1316 fn format_filter_violation_failed_includes_the_underlying_error() {
1317 let msg = format_filter_violation(
1318 "a",
1319 &FilterViolation::Failed(anyhow::anyhow!("out of range")),
1320 );
1321 assert!(msg.contains('a'));
1322 assert!(msg.contains("out of range"));
1323 }
1324
1325 fn dummy_cell() -> CellId {
1326 let mut sheet = Sheet::new();
1327 sheet.add_cell(0_i32)
1328 }
1329
1330 #[test]
1331 fn slider_invalid_message_prefers_a_requirement_name_when_present() {
1332 let sheet = Sheet::new();
1333 let id = dummy_cell();
1334 let status = status_full(true, &[], &[], &[], &[], &[], &[(id, "not_too_big")]);
1335 assert_eq!(
1336 slider_invalid_message(id, "a", &sheet, &status),
1337 Some("not_too_big".to_string())
1338 );
1339 }
1340
1341 #[test]
1342 fn slider_invalid_message_reports_a_filter_violation_when_no_requirement_name_applies() {
1343 use adam_rs::{Filter, Method};
1344
1345 let mut sheet = Sheet::new();
1346 let a = sheet.add_cell(0.0_f64);
1347 let b = sheet.add_cell(0.0_f64);
1348 sheet
1349 .add_filter(a, Filter::from_fn_0(|x: &f64| Ok(x.clamp(0.0, 100.0))))
1350 .unwrap();
1351 sheet
1352 .add_relationship(vec![Method::from_fn_1_1(b, a, |v: &f64| Ok(*v))])
1353 .unwrap();
1354 sheet.write(b, -30.0_f64).unwrap();
1355 sheet.propagate().unwrap();
1356
1357 let status = status(false, &[], &[], &[]);
1358 let msg = slider_invalid_message(a, "a", &sheet, &status).unwrap();
1359 assert!(msg.contains("does not conform"));
1360 }
1361
1362 #[test]
1363 fn slider_invalid_message_falls_back_to_a_generic_message_for_a_mere_contributor() {
1364 let sheet = Sheet::new();
1365 let id = dummy_cell();
1366 let status = status(true, &[], &[id], &[]);
1367 assert_eq!(
1368 slider_invalid_message(id, "b", &sheet, &status),
1369 Some("`b` is invalid".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn slider_invalid_message_falls_back_to_a_generic_message_for_an_unnamed_invalid_output() {
1375 let sheet = Sheet::new();
1376 let id = dummy_cell();
1377 let status = status(true, &[], &[], &[id]);
1378 assert_eq!(
1379 slider_invalid_message(id, "c", &sheet, &status),
1380 Some("`c` is invalid".to_string())
1381 );
1382 }
1383
1384 #[test]
1385 fn slider_invalid_message_none_when_nothing_flags_the_cell() {
1386 let sheet = Sheet::new();
1387 let id = dummy_cell();
1388 let status = status(false, &[], &[], &[]);
1389 assert_eq!(slider_invalid_message(id, "d", &sheet, &status), None);
1390 }
1391
1392 #[test]
1393 fn cell_flags_enabled_when_no_outputs_even_if_not_relevant() {
1394 let id = dummy_cell();
1395 let flags = cell_flags(id, false, false, &status(false, &[], &[], &[]));
1396 assert!(!flags.disabled);
1397 }
1398
1399 #[test]
1400 fn cell_flags_disabled_when_forced_regardless_of_outputs() {
1401 let id = dummy_cell();
1402 let flags = cell_flags(id, true, false, &status(false, &[], &[], &[]));
1403 assert!(flags.disabled);
1404 }
1405
1406 #[test]
1407 fn cell_flags_disabled_when_has_outputs_and_cell_not_relevant() {
1408 let mut sheet = Sheet::new();
1412 let id = sheet.add_cell(0_i32);
1413 let other = sheet.add_cell(0_i32);
1414 let flags = cell_flags(id, false, false, &status(true, &[other], &[], &[]));
1415 assert!(flags.disabled);
1416 }
1417
1418 #[test]
1419 fn cell_flags_enabled_when_has_outputs_and_cell_is_relevant() {
1420 let id = dummy_cell();
1421 let flags = cell_flags(id, false, false, &status(true, &[id], &[], &[]));
1422 assert!(!flags.disabled);
1423 }
1424
1425 #[test]
1426 fn cell_flags_invalid_when_has_error() {
1427 let id = dummy_cell();
1428 let flags = cell_flags(id, false, true, &status(false, &[], &[], &[]));
1429 assert!(flags.invalid);
1430 }
1431
1432 #[test]
1433 fn cell_flags_invalid_when_cell_is_an_invalid_output() {
1434 let id = dummy_cell();
1435 let flags = cell_flags(id, false, false, &status(true, &[id], &[], &[id]));
1436 assert!(flags.invalid);
1437 }
1438
1439 #[test]
1440 fn cell_flags_invalid_when_cell_is_filter_violated() {
1441 let id = dummy_cell();
1442 let flags = cell_flags(
1443 id,
1444 false,
1445 false,
1446 &status_with_filter_violated(true, &[id], &[], &[], &[id]),
1447 );
1448 assert!(flags.invalid);
1449 }
1450
1451 #[test]
1452 fn cell_flags_readonly_when_cell_is_an_output_cell() {
1453 let id = dummy_cell();
1454 let flags = cell_flags(
1455 id,
1456 true,
1457 false,
1458 &status_full(true, &[], &[], &[], &[], &[id], &[]),
1459 );
1460 assert!(flags.readonly);
1461 }
1462
1463 #[test]
1464 fn cell_flags_not_readonly_when_cell_is_not_an_output_cell() {
1465 let id = dummy_cell();
1466 let flags = cell_flags(id, false, false, &status(false, &[], &[], &[]));
1467 assert!(!flags.readonly);
1468 }
1469
1470 #[test]
1471 fn cell_flags_readonly_and_invalid_together_when_an_output_cell_fails_its_requirement() {
1472 let id = dummy_cell();
1473 let flags = cell_flags(
1474 id,
1475 true,
1476 false,
1477 &status_full(true, &[], &[], &[id], &[], &[id], &[]),
1478 );
1479 assert!(flags.readonly);
1480 assert!(flags.invalid);
1481 }
1482
1483 #[test]
1484 fn cell_flags_invalid_when_cell_is_an_invalid_contributor() {
1485 let id = dummy_cell();
1489 let flags = cell_flags(id, false, false, &status(true, &[id], &[id], &[]));
1490 assert!(flags.invalid);
1491 }
1492
1493 #[test]
1494 fn clamped_away_false_when_typed_matches_actual() {
1495 assert!(!clamped_away("42", "42"));
1496 }
1497
1498 #[test]
1499 fn clamped_away_true_when_a_range_filter_clamped_the_value() {
1500 assert!(clamped_away("150", "100"));
1501 }
1502
1503 #[test]
1504 fn clamped_away_ignores_display_rounding_differences() {
1505 assert!(!clamped_away("1.005", &format_rounded(1.005)));
1506 }
1507
1508 #[test]
1509 fn clamped_away_false_for_non_numeric_input() {
1510 assert!(!clamped_away("not a number", "0"));
1511 }
1512
1513 #[test]
1514 fn slider_bounds_returns_range_unchanged_when_current_is_within_it() {
1515 assert_eq!(
1516 slider_bounds("50", (0.0, 100.0)),
1517 ("0".to_string(), "100".to_string())
1518 );
1519 }
1520
1521 #[test]
1522 fn slider_bounds_widens_max_to_include_a_current_value_above_it() {
1523 assert_eq!(
1524 slider_bounds("150", (0.0, 100.0)),
1525 ("0".to_string(), "150".to_string())
1526 );
1527 }
1528
1529 #[test]
1530 fn slider_bounds_widens_min_to_include_a_current_value_below_it() {
1531 assert_eq!(
1532 slider_bounds("-50", (0.0, 100.0)),
1533 ("-50".to_string(), "100".to_string())
1534 );
1535 }
1536
1537 #[test]
1538 fn slider_bounds_falls_back_to_the_unwidened_range_when_current_does_not_parse() {
1539 assert_eq!(
1540 slider_bounds("not a number", (0.0, 100.0)),
1541 ("0".to_string(), "100".to_string())
1542 );
1543 }
1544
1545 #[test]
1546 fn toggled_bool_value_true_becomes_false() {
1547 assert_eq!(toggled_bool_value("true"), "false");
1548 }
1549
1550 #[test]
1551 fn toggled_bool_value_false_becomes_true() {
1552 assert_eq!(toggled_bool_value("false"), "true");
1553 }
1554
1555 #[test]
1556 fn dom_id_namespace_is_deterministic_for_the_same_name() {
1557 assert_eq!(
1558 dom_id_namespace("tutorial/first_sheet.adm2"),
1559 dom_id_namespace("tutorial/first_sheet.adm2")
1560 );
1561 }
1562
1563 #[test]
1564 fn dom_id_namespace_differs_for_different_names() {
1565 assert_ne!(
1566 dom_id_namespace("tutorial/first_sheet.adm2"),
1567 dom_id_namespace("tutorial/multiplication_triangle.adm2")
1568 );
1569 }
1570
1571 #[test]
1572 fn dom_id_namespace_contains_only_ascii_digits() {
1573 let ns = dom_id_namespace("tutorial/first_sheet.adm2").to_string();
1574 assert!(ns.chars().all(|c| c.is_ascii_digit()));
1575 }
1576}