Skip to main content

adam_web_ui/
inspector.rs

1//! [`SheetInspector`] — a live, editable list of a sheet's cells with a write form.
2
3use 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/// Aggregate out-cell status for the whole sheet, computed once per render and shared by
17/// every `CellRow` so `Sheet::requirement_relevant_cells`/`requirement_violation_cells` run
18/// once instead of once per row.
19#[derive(Clone, PartialEq)]
20struct OutputStatus {
21    /// `true` if the sheet has at least one output.
22    has_outputs: bool,
23    /// Union of three sets: `Sheet::contributing_cells(id)` for every `id` in
24    /// `Sheet::out_cells()`; `Sheet::requirement_relevant_cells()`, taken as-is (it already
25    /// returns contributing cells internally, so it is not run through `contributing_cells`
26    /// again here); and every conditional's match cell, added directly, not traced.
27    ///
28    /// The `contributing_cells` term restores the pre-generalization guarantee that an
29    /// out cell's contributors are always relevant, regardless of whether that out cell
30    /// has any requirements attached — `requirement_relevant_cells()` alone only covers
31    /// cells feeding a requirement, so an out cell with zero requirements would otherwise
32    /// silently drop out of "relevant" even though it's still an out cell whose inputs
33    /// the user should see as meaningful.
34    ///
35    /// `Sheet::contributing_cells` never traces back through a conditional's match
36    /// cell (it only follows relationship method inputs), so without the match-cell
37    /// addition a conditional's own switch could be marked "don't care" and disabled
38    /// once the sheet has any output — blocking the toggle that controls which branch is
39    /// active. Match cells are therefore always treated as relevant, independent of
40    /// which branch is currently active.
41    relevant: HashSet<CellId>,
42    /// Union of `Sheet::requirement_violation_cells()` and `Sheet::filter_violation_cells()`
43    /// — the root cells that produced a violating value. Rendered with the same `invalid`
44    /// state as the cell actually carrying the violation (see `invalid_outputs`/
45    /// `filter_violated`): Spectrum has no notion of "implicated but not itself invalid",
46    /// and inventing one (a past version of this code used a CSS class literally named
47    /// `warning`) collided with `mdbook`'s own book-wide `.warning` admonition styles —
48    /// an unrelated `ⓘ` callout icon rendered wherever this class landed, since class
49    /// names aren't scoped to a component the way shadow-DOM parts are.
50    invalid_contributors: HashSet<CellId>,
51    /// Cells backing an output whose `Sheet::cell_requirements_valid` is currently `false`.
52    invalid_outputs: HashSet<CellId>,
53    /// `Sheet::filter_violated_cells()` — cells whose own filter didn't hold, shown the
54    /// same way a parse error is: this is the cell's own value that's out of domain, not
55    /// just a contributor to someone else's.
56    filter_violated: HashSet<CellId>,
57    /// Cells backing any live `out` declaration, via `Sheet::out_cells`, regardless of
58    /// whether its requirements currently hold. An output's cell is always `forced` (see
59    /// [`cell_flags`]), so without this it would render as `disabled` like any other
60    /// non-writable cell — hiding a failed `require`'s `invalid` treatment, since a
61    /// disabled `sp-number-field` suppresses `invalid` styling entirely. This set lets a
62    /// numeric out cell render `readonly` instead, which keeps `invalid` visible.
63    output_cells: HashSet<CellId>,
64    /// Violated requirement names for each currently-invalid output, joined for display and
65    /// keyed by the output's own cell — via `Sheet::violated_requirements`/
66    /// `ParsedSheet::requirement_names`. A cell absent from this map has no currently-failing
67    /// explicitly labeled requirement; unnamed failures still contribute to `invalid_outputs`.
68    invalid_output_requirement_names: HashMap<CellId, String>,
69}
70
71/// Computes `parsed`'s current out-cell status and resolves caller-owned labels.
72///
73/// - Complexity: O(sum of `Sheet::contributing_cells` cost over every out cell +
74///   `Sheet::requirement_relevant_cells` + `Sheet::requirement_violation_cells` +
75///   `Sheet::filter_violation_cells` + the number of conditionals in the sheet +
76///   violated requirements and their total label length), with expected O(1) label lookup.
77fn compute_output_status(parsed: &ParsedSheet) -> OutputStatus {
78    let sheet = &parsed.sheet;
79    // `Sheet::out_cells()` now returns each out cell's own `CellId` directly — the
80    // `OutputId` → `CellId` lookup this function used to do via `Sheet::output_cell` is
81    // gone because there's no longer a separate handle to look up.
82    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
128/// Formats a diagnostic message for `label`'s filter `violation`, for
129/// `crate::diagnostics::report_error`.
130fn 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
141/// Returns the message to show as a range-filtered cell's own invalid indicator.
142///
143/// `sp-slider` (unlike `SpNumberfield`/`SpCheckbox`/`SpTextfield`) has no `invalid` visual
144/// state of its own (see `SpSlider`'s doc comment), so this sibling `SpHelpText` is the only
145/// way a slider-rendered cell can surface a violation — every other control instead relies on
146/// its own `invalid` prop and only adds this same text as a supplementary detail.
147///
148/// Prefers, in order: a currently-violated `require` name attached to `id` (only possible when
149/// `id` is an out cell), then `id`'s own currently-violated filter, then a generic fallback
150/// naming `label` when `id` is flagged invalid for some other reason (e.g. it merely
151/// contributes to another cell's violated filter or requirement).
152///
153/// - Postcondition: `None` when `id` is in none of `status.invalid_outputs`,
154///   `status.invalid_contributors`, and has no currently-violated filter (`Sheet::filter_violation`).
155fn 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/// A cell's Inspector display flags, derived from its own forced/error state and the
174/// sheet-wide out-cell status.
175#[derive(Clone, Copy, PartialEq, Eq)]
176struct CellFlags {
177    disabled: bool,
178    invalid: bool,
179    /// `true` when `id` backs a live `out` declaration. A numeric field uses this to
180    /// render `readonly` instead of `disabled` — see [`OutputStatus::output_cells`] for
181    /// why `disabled` alone would hide a failed `require`'s `invalid` treatment.
182    readonly: bool,
183}
184
185/// Derives `id`'s Inspector display flags from its own `forced`/`has_error` state and the
186/// sheet-wide `status`.
187fn 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
201/// Returns the toggled value ("true"/"false") for a bool cell currently displaying `current`.
202fn toggled_bool_value(current: &str) -> &'static str {
203    if current == "true" { "false" } else { "true" }
204}
205
206/// Returns a decimal digit string derived from `source_name`, for namespacing a cell's DOM
207/// `id` so it stays unique across independently-mounted sheets on the same page.
208///
209/// `CellId` numbering restarts from its first value in every independent `Sheet`, so two
210/// different `.adm2` examples mounted on the same book page (each its own `SheetInspector`
211/// instance) can and do produce identical `CellId` debug strings; without this namespace,
212/// `document.getElementById` — used throughout this module's JS bridges instead of
213/// `event.target`, since custom elements never populate that — would silently resolve to
214/// whichever mount's element happens to appear first in the document, corrupting reads and
215/// writes for every other mount. Hashing (rather than embedding `source_name` verbatim) keeps
216/// the result composed only of ASCII digits, safe to interpolate directly into the JS string
217/// literals built via `format!` elsewhere in this module.
218///
219/// - Postcondition: the same `source_name` always yields the same result; only ASCII digits
220///   appear in the result.
221fn 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
227/// Returns the `min`/`max` bounds to pass to a range-filtered numeric cell's [`SpSlider`]:
228/// `range`'s bounds, widened if necessary so `current` (the field's own displayed text) always
229/// falls within them.
230///
231/// `sp-slider` (and, when `editable`, its inline `sp-number-field`) clamps its displayed value
232/// to fit whatever `min`/`max` it's given — not just in response to user input, but on *any*
233/// update to `min`, `max`, or `value` where the three momentarily disagree — mirroring
234/// `sp-number-field`'s own documented clamp-on-update behavior (see [`SpNumberfield`]). A range
235/// filter's live bounds (`range`, recomputed from the filter's *current* argument values) can
236/// transiently exclude a cell's actual stored value: the filter only re-clamps a cell at the
237/// moment that cell itself is written, so changing another cell its bounds depend on (e.g. a
238/// shared `max` cell) does not retroactively pull this cell back in range. Widening `range` to
239/// always include `current` guarantees the two never disagree, so the widget never mis-clamps —
240/// at the cost of its handle/stepper not disabling exactly at the filter's true limit while a
241/// cell sits outside it, until the cell's own next write brings it back in range and the true
242/// bounds resume being enforced.
243fn 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
249/// Returns Spectrum display options for a numeric cell, preserving integer defaults and
250/// displaying floating-point values with exactly two fractional digits.
251fn 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
259/// Returns slider increments of one for integer cells and one hundredth for floating-point cells.
260///
261/// The fractional increment matches the Inspector's two-decimal display precision.
262fn slider_step(is_integer: bool) -> Option<&'static str> {
263    Some(if is_integer { "1" } else { "0.01" })
264}
265
266/// Represents an empty number-field draft as `NaN`, avoiding JavaScript's empty-string-to-zero conversion.
267fn number_field_value(input: &str) -> &str {
268    if input.is_empty() { "NaN" } else { input }
269}
270
271/// Keeps the slider's numeric handle at the computed value while its inline draft is empty.
272fn slider_field_value<'a>(input: &'a str, computed: &'a str) -> &'a str {
273    if input.is_empty() { computed } else { input }
274}
275
276/// Preserves a blank numeric draft until its computed value changes, including across blur.
277///
278/// Nonempty drafts retain the existing focus-based synchronization policy.
279/// - Complexity: O(n) in the computed value text lengths.
280fn 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
288/// Reads a normalized numeric draft from a Spectrum field or a slider's editable field.
289///
290/// Consumes the localized `adamNumericDraft` installed by [`SpSlider`] once, preserving blanks
291/// and partial edits. Slider dragging reads the canonical host value without locale normalization.
292/// The script sends `1|value` while the host is focused and `0|value` otherwise.
293/// - Complexity: O(n) in the field ID and edited text lengths.
294fn 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
305/// Returns `true` if `typed`, read as a number, differs from `actual` (a cell's post-write
306/// display string) after applying [`format_rounded`]'s own rounding to both — i.e. a range
307/// filter silently clamped the written value to something other than what was typed. A range
308/// filter's `write_str`/`Sheet::propagate` both return `Ok` in that case (clamping always
309/// produces some valid value), so this is the only place that distinguishes it from an
310/// unmodified write.
311///
312/// - Postcondition: returns `false` whenever `typed` doesn't parse as `f64` — a non-numeric
313///   cell's write is never treated as clamped.
314fn 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/// The source context needed to format an evaluation error into a rustc-style diagnostic:
322/// the current source text and name, plus the parsed sheet whose span tables resolve an
323/// `adam_rs::Error`'s sites back to their declaration locations.
324#[derive(Clone, Copy)]
325struct ErrorContext {
326    source_text: Memo<String>,
327    source_name: Memo<String>,
328    parsed: Signal<ParsedSheet>,
329}
330
331/// Parses `val` for `id` via its `Labels` metadata, writes it to `parsed`, and propagates the
332/// sheet's constraints, updating `has_error` and reporting any error — or, on success, any
333/// currently-violated filter — to `crate::diagnostics`.
334///
335/// An empty numeric draft clears its parse error without writing or propagating; empty
336/// nonnumeric values still pass through the cell's parser.
337///
338/// - Postcondition: `has_error` is `true` on parse or propagation failure, or when a range
339///   filter clamped `val` away from what was typed (see [`clamped_away`]); `false` otherwise.
340fn 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 the write guard before reading `errors.parsed` (the same signal) again
385            // below — `format_adam_error` needs `&ParsedSheet` to locate `e`'s sites, and
386            // the write guard and a fresh read both borrowing the same signal at once would
387            // panic.
388            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/// Sidebar panel showing all cells with labels and inputs for writing — a checkbox for
402/// `bool`-typed cells, a single editable slider (its label built in) for a numeric cell with a
403/// range filter, a plain number field for any other numeric cell, and a text field for
404/// everything else.
405///
406/// Editing an input immediately writes the parsed value to the sheet and propagates
407/// constraints. If parsing or propagation fails (for example, non-numeric input or division
408/// by zero), the field renders in its invalid state until the user blurs, and the formatted
409/// diagnostic is printed to stderr. A field's input is not reset while it is focused; it
410/// syncs back to the computed value on blur, keeping non-edited cells up to date.
411#[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    // `None` for a cell with no currently-failing labeled `require`, including every non-output
513    // cell — set from `OutputStatus::invalid_output_requirement_names`, itself keyed by
514    // `Sheet::violated_requirements`/`ParsedSheet::requirement_names`.
515    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    // Only consumed by the slider branch below — `sp-slider` has no native `invalid` state,
524    // so it needs the full message (filter violation or generic fallback), not just a
525    // requirement name.
526    let slider_message = use_memo(move || {
527        slider_invalid_message(id, &label.read(), &parsed.read(), &output_status.read())
528    });
529
530    // Sync input to the computed value whenever it changes, but not while the user
531    // is actively editing — that would interrupt mid-value typing (e.g. "1." → "1").
532    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                                    // Dragging needs no focus; inline typing reads its raw draft
571                                    // instead of the slider's last committed numeric value.
572                                    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                        // `sp-slider` has no `invalid` state or `negative-help-text` slot the
590                        // way `sp-number-field` does (see `SpSlider`'s doc comment), so any
591                        // violation on a range-filtered cell — a failing `require` on an out
592                        // cell, or the cell's own filter rejecting its forced value (see
593                        // `slider_invalid_message`) — is surfaced as a plain sibling
594                        // `SpHelpText` instead of slotted content — its parent here is a plain
595                        // `div`, not a shadow host, so `slot` has no effect; "negative-help-text"
596                        // is passed anyway (rather than an empty string) to match the name used
597                        // for the same purpose in the non-range branch below. `slider_message`
598                        // is `None` exactly when `flags.invalid` would be `false` for a non-slider
599                        // control (see `slider_invalid_message`'s postcondition), so this is
600                        // already gated on invalidity without needing to check `flags` directly.
601                        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                // `sp-checkbox`'s label is its own light-DOM child text, not a sibling
608                // `SpFieldLabel` (which renders as a separate line above the checkbox,
609                // disconnected from it) -- unlike the numeric/text fields below, which
610                // have no such built-in label slot.
611                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                        // `sp-checkbox` toggles its own shadow-DOM `checked` state
620                        // natively in response to the click, before this handler runs
621                        // and independent of the `checked` prop below. If the write above
622                        // was rejected, `value` recomputes to the same string as before,
623                        // so Dioxus's diff sees no change and never re-touches the DOM —
624                        // leaving the visual checkbox desynced from the sheet. Force the
625                        // element back to the actual committed value here.
626                        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                        // An out cell's field is always `disabled` too (its cell is
649                        // always `forced`, never a candidate write target), but
650                        // `readonly` is what actually renders here — a disabled
651                        // `sp-number-field` suppresses `invalid` styling, which would
652                        // hide a failed `require`.
653                        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                                // Reads the shadow-DOM `<input>`'s raw text, not the host's
659                                // `value` property: once `min`/`max` are set, `sp-number-field`
660                                // clamps its own `value` to that range on every keystroke,
661                                // which would hide an out-of-range-for-type entry from
662                                // `write_and_propagate` below before it ever sees the digits
663                                // the user actually typed.
664                                //
665                                // `sp-number-field` displays (and lets the user type) numbers
666                                // grouped and decimal-marked per the resolved locale — e.g.
667                                // `"1,920"` in `en`, `"1.920,5"` in `de`. `el.numberFormatter`
668                                // is the exact `Intl.NumberFormat` the element itself renders
669                                // with, so reading its group/decimal separators here (rather
670                                // than assuming `,`/`.`) and normalizing the raw text to a
671                                // plain `.`-decimal, ungrouped string keeps this locale-aware
672                                // for any resolved locale. Rust only ever sees that normalized
673                                // form, both for `write_and_propagate` below and for the
674                                // `value` this component's `value` prop round-trips back —
675                                // `sp-number-field`'s host `value` is a plain JS `Number`
676                                // property, so echoing back ungrouped text is required to
677                                // avoid the element itself parsing it as `NaN`.
678                                //
679                                // The stepper buttons (and scroll-wheel/arrow-key stepping)
680                                // change `value` and fire this same `input` event without ever
681                                // focusing the field the way Dioxus's `onfocus` can observe:
682                                // `sp-number-field::stepBy()` calls the DOM `.focus()` method
683                                // directly on itself, which moves `document.activeElement` but
684                                // — unlike focus arriving from an actual pointer/keyboard
685                                // interaction with the field — never dispatches a `focus` event
686                                // Dioxus's delegated listener sees, so `is_focused` stays stuck
687                                // at `false` and every stepper click was silently dropped below.
688                                // Reading `document.activeElement` fresh here, instead of
689                                // trusting the `is_focused` signal, sidesteps that gap while
690                                // still discarding a response that arrives after a genuine
691                                // blur-while-in-flight (`activeElement` has moved on by then).
692                                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                        // `sp-number-field` only exposes its `negative-help-text` slot
712                        // while its own `invalid` prop is `true` (SWC's `HelpTextManager`
713                        // renders `<slot name="negative-help-text">` vs. a discarded
714                        // pass-through slot based on exactly that), so this always mounts
715                        // when there's a name to show — never independently gated on
716                        // `invalid` here, since `violated_requirement_names` is already
717                        // empty whenever `invalid` is false for an output cell (see
718                        // `OutputStatus::invalid_output_requirement_names`). Names the
719                        // `require` currently failing on this out cell — a stopgap: a real
720                        // message (see `Requirement::from_fn_*`'s own
721                        // `require { @name expression; }` source) would need the sheet to
722                        // carry more than just a name, so this just surfaces the name a
723                        // sheet author already chose.
724                        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                        // Dioxus's event serializer only reads event.target.value for
735                        // HTMLInputElement — custom elements (sp-textfield) always give "".
736                        // Use dioxus.send() in JS and eval.recv() to read the live value.
737                        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                                // Discard the result if the user blurred while the round-trip was
745                                // in flight; blur already cleared the error and use_effect will
746                                // restore the last valid computed value.
747                                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    /// Verifies blank numeric drafts stay blank through blur and unrelated recomputation.
772    #[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    /// Verifies nonempty and nonnumeric drafts retain focus-based synchronization.
781    #[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    /// Verifies empty numeric drafts do not become JavaScript zero values.
792    #[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    /// Verifies clearing a slider draft preserves its handle and the bounds that contain it.
801    #[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        /// Records whether the live-write fixture completed its assertions outside Dioxus's error boundary.
813        static EMPTY_WRITES_CHECKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
814    }
815
816    /// Exercises empty writes for numeric cells and a string cell through the live write path.
817    ///
818    /// - Complexity: One sheet parse and three writes.
819    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    /// Verifies blank numeric edits leave stored values unchanged while empty strings remain writable.
868    #[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    /// Verifies that both floating-point types configure fractional slider increments.
879    #[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    /// Verifies that integer cells retain whole-number slider increments.
890    #[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    /// Verifies that both floating-point types request exactly two fractional digits.
901    #[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    /// Verifies that integer widgets keep Spectrum's existing formatting.
915    #[test]
916    fn numeric_format_options_preserves_integer_defaults() {
917        assert_eq!(numeric_format_options(true), None);
918    }
919
920    /// Verifies the composed example preserves its starting aspect and both edit directions when locked.
921    #[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    /// Parses a valid test sheet with the standard type registry.
941    ///
942    /// - Complexity: The parser's source compilation cost.
943    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    /// Wraps a runtime-only fixture without attaching display metadata.
950    fn parsed_with_sheet(sheet: Sheet) -> ParsedSheet {
951        let mut parsed = parse("sheet s {}");
952        parsed.sheet = sheet;
953        parsed
954    }
955
956    /// Builds a real parsed binding with a native failing method at the same registered IDs.
957    ///
958    /// The native method has no CEL `SpanContext`, so diagnostics must use the parser's
959    /// actual binding span rather than expression error context.
960    ///
961    /// - Precondition: `source` declares exactly two i32 cells and one identity binding.
962    /// - Complexity: The parser's source compilation cost.
963    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    /// Exercises live writes before and after replacing the parsed sheet in one signal.
998    ///
999    /// - Complexity: Two parses and two propagations.
1000    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    /// Verifies the real live-write error channel retains filenames and fresh binding spans.
1051    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    /// Marks unnamed failures invalid without manufacturing a display label.
1248    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        // `add_requirement` hard-fails immediately if a plain/source cell's *current*
1272        // value already violates it, so the requirement is attached while it still
1273        // holds (200 > 100), then a later `write` — not the initial value — is what
1274        // drives it into violation.
1275        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        // Regression test for the `relevant`-set narrowing: `requirement_relevant_cells()`
1291        // alone only covers cells feeding a requirement, so an out cell with zero
1292        // requirements would otherwise silently drop out of "relevant" even though it's
1293        // still an out cell whose inputs the user should see as meaningful.
1294        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        // Both ids must come from the same Sheet: two fresh Sheets' first added cell
1409        // return equal CellId values (slotmap's key generation is deterministic per
1410        // map), which would make `id` and `other` indistinguishable below.
1411        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        // A cell that merely contributes to a failing out-cell condition renders
1486        // `invalid` too — Spectrum has no softer "implicated but not itself invalid"
1487        // state to reach for instead.
1488        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}