Skip to main content

presolve_compiler/
form_tracking.rs

1//! I8 declaration-level dirty and touched tracking plans.
2//!
3//! These products describe compiler-owned tracking semantics only. They do not
4//! allocate runtime state, execute transitions, schedule validation, or create
5//! Form instances.
6
7use std::collections::BTreeMap;
8
9use crate::{
10    DirtyTrackingPlanId, ExecutionBoundary, FieldBindingId, FieldId, FieldTrackingId, FormEntity,
11    FormFieldBinding, FormFieldEntity, FormId, FormOwnershipGraph, SemanticId, SerializableValue,
12    SourceProvenance, TouchedTrackingPlanId,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DirtyTransitionCause {
17    CommittedFieldValueWrite,
18    ResetField,
19    ResetForm,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TouchedTransitionCause {
24    BoundControlBlur,
25    ResetField,
26    ResetForm,
27}
28
29/// Immutable dirty transition facts for one canonical Field.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct DirtyTransitionPlan {
32    pub committed_write: DirtyTransitionCause,
33    pub reset_field: DirtyTransitionCause,
34    pub reset_form: DirtyTransitionCause,
35    pub compares_with_initial_value: bool,
36    pub may_handoff_to_direct_validation_dependencies: bool,
37}
38
39/// Immutable touched transition facts for one canonical Field.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct TouchedTransitionPlan {
42    pub blur: Option<TouchedTransitionCause>,
43    pub reset_field: TouchedTransitionCause,
44    pub reset_form: TouchedTransitionCause,
45    pub may_handoff_blur_to_target_validation: bool,
46}
47
48/// I8 dirty facts for one valid I3 Form Field.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FieldDirtyTracking {
51    pub id: FieldTrackingId,
52    pub plan: DirtyTrackingPlanId,
53    pub field: FieldId,
54    pub form: FormId,
55    pub component: SemanticId,
56    pub initial_value: SerializableValue,
57    pub initial_dirty: bool,
58    pub declaration_order: usize,
59    pub transitions: DirtyTransitionPlan,
60    pub provenance: SourceProvenance,
61    pub initializer_provenance: SourceProvenance,
62    pub boundary: ExecutionBoundary,
63}
64
65/// I8 touched facts for one valid I3 Form Field. All radio bindings for a
66/// Field intentionally share this one record and touched flag.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FieldTouchedTracking {
69    pub id: FieldTrackingId,
70    pub plan: TouchedTrackingPlanId,
71    pub field: FieldId,
72    pub form: FormId,
73    pub component: SemanticId,
74    pub initial_touched: bool,
75    pub declaration_order: usize,
76    pub blur_bindings: Vec<FieldBindingId>,
77    pub transitions: TouchedTransitionPlan,
78    pub provenance: SourceProvenance,
79    pub boundary: ExecutionBoundary,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct DirtyTrackingPlan {
84    pub id: DirtyTrackingPlanId,
85    pub form: FormId,
86    pub component: SemanticId,
87    pub fields: Vec<FieldTrackingId>,
88    pub validation: FormTrackingValidation,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct TouchedTrackingPlan {
93    pub id: TouchedTrackingPlanId,
94    pub form: FormId,
95    pub component: SemanticId,
96    pub fields: Vec<FieldTrackingId>,
97    pub validation: FormTrackingValidation,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct FormTrackingIntegrityDiagnostic {
102    pub code: String,
103    pub kind: FormTrackingIntegrityKind,
104    pub message: String,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
108pub enum FormTrackingIntegrityKind {
109    MissingForm,
110    MissingField,
111    PlanIdentityMismatch,
112    TrackingIdentityMismatch,
113    FieldFormMismatch,
114    ComponentMismatch,
115    InitialValueMismatch,
116    BindingMismatch,
117    DuplicateTrackingRecord,
118    MissingTrackingRecord,
119    MissingProvenance,
120    NonCanonicalOrdering,
121}
122
123impl FormTrackingIntegrityKind {
124    #[must_use]
125    pub const fn code(self) -> &'static str {
126        match self {
127            Self::MissingForm => "PSASM1273",
128            Self::MissingField => "PSASM1274",
129            Self::PlanIdentityMismatch => "PSASM1275",
130            Self::TrackingIdentityMismatch => "PSASM1276",
131            Self::FieldFormMismatch => "PSASM1277",
132            Self::ComponentMismatch => "PSASM1278",
133            Self::InitialValueMismatch => "PSASM1279",
134            Self::BindingMismatch => "PSASM1280",
135            Self::DuplicateTrackingRecord => "PSASM1281",
136            Self::MissingTrackingRecord => "PSASM1282",
137            Self::MissingProvenance => "PSASM1283",
138            Self::NonCanonicalOrdering => "PSASM1284",
139        }
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct FormTrackingValidation {
145    pub diagnostics: Vec<FormTrackingIntegrityDiagnostic>,
146    pub is_valid: bool,
147}
148
149impl Default for FormTrackingValidation {
150    fn default() -> Self {
151        Self {
152            diagnostics: Vec::new(),
153            is_valid: true,
154        }
155    }
156}
157
158/// Complete immutable I8 product. This is declaration-level only and is not
159/// an additional ASM ownership authority or public schema.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct DirtyTrackingGraph {
162    pub plans: BTreeMap<DirtyTrackingPlanId, DirtyTrackingPlan>,
163    pub fields: BTreeMap<FieldTrackingId, FieldDirtyTracking>,
164    pub validation: FormTrackingValidation,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct TouchedTrackingGraph {
169    pub plans: BTreeMap<TouchedTrackingPlanId, TouchedTrackingPlan>,
170    pub fields: BTreeMap<FieldTrackingId, FieldTouchedTracking>,
171    pub validation: FormTrackingValidation,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct FormTrackingProducts {
176    pub dirty: DirtyTrackingGraph,
177    pub touched: TouchedTrackingGraph,
178}
179
180impl DirtyTrackingGraph {
181    #[must_use]
182    pub fn plan(&self, form: &FormId) -> Option<&DirtyTrackingPlan> {
183        self.plans.get(&DirtyTrackingPlanId::for_form(form))
184    }
185
186    #[must_use]
187    pub fn tracking(&self, field: &FieldId) -> Option<&FieldDirtyTracking> {
188        self.fields.get(&FieldTrackingId::for_field(field))
189    }
190
191    /// Pure I8 transition query. It computes the recorded structural-equality
192    /// rule; it does not mutate runtime state or schedule validation.
193    #[must_use]
194    pub fn dirty_after_committed_write(
195        &self,
196        field: &FieldId,
197        current: &SerializableValue,
198    ) -> Option<bool> {
199        self.tracking(field).map(|tracking| {
200            !structurally_equal_serializable_values(current, &tracking.initial_value)
201        })
202    }
203}
204
205impl TouchedTrackingGraph {
206    #[must_use]
207    pub fn plan(&self, form: &FormId) -> Option<&TouchedTrackingPlan> {
208        self.plans.get(&TouchedTrackingPlanId::for_form(form))
209    }
210
211    #[must_use]
212    pub fn tracking(&self, field: &FieldId) -> Option<&FieldTouchedTracking> {
213        self.fields.get(&FieldTrackingId::for_field(field))
214    }
215
216    #[must_use]
217    pub fn blur_bindings(&self, field: &FieldId) -> Vec<&FieldBindingId> {
218        self.tracking(field)
219            .map_or_else(Vec::new, |tracking| tracking.blur_bindings.iter().collect())
220    }
221}
222
223/// Canonical recursive equality is `SerializableValue` structural equality.
224#[must_use]
225pub fn structurally_equal_serializable_values(
226    left: &SerializableValue,
227    right: &SerializableValue,
228) -> bool {
229    left == right
230}
231
232#[must_use]
233#[allow(clippy::too_many_lines)]
234/// # Panics
235///
236/// Panics when a supposedly valid I2 Form lacks its required canonical
237/// Component owner. That is a violated pre-I8 invariant.
238pub fn collect_form_tracking_products(
239    forms: &BTreeMap<FormId, FormEntity>,
240    fields: &BTreeMap<FieldId, FormFieldEntity>,
241    bindings: &BTreeMap<FieldBindingId, FormFieldBinding>,
242    ownership: &FormOwnershipGraph,
243) -> FormTrackingProducts {
244    let mut dirty = DirtyTrackingGraph {
245        plans: BTreeMap::new(),
246        fields: BTreeMap::new(),
247        validation: FormTrackingValidation::default(),
248    };
249    let mut touched = TouchedTrackingGraph {
250        plans: BTreeMap::new(),
251        fields: BTreeMap::new(),
252        validation: FormTrackingValidation::default(),
253    };
254
255    for form in forms.values() {
256        let dirty_id = DirtyTrackingPlanId::for_form(&form.id);
257        let touched_id = TouchedTrackingPlanId::for_form(&form.id);
258        let fields_for_form = ordered_fields(fields, &form.id);
259        let mut tracking_ids = Vec::with_capacity(fields_for_form.len());
260
261        for field in fields_for_form {
262            let tracking_id = FieldTrackingId::for_field(&field.id);
263            tracking_ids.push(tracking_id.clone());
264            let blur_bindings = ordered_bindings_for_field(bindings, &field.id)
265                .into_iter()
266                .filter(|binding| {
267                    binding.form == form.id
268                        && binding.component == field.owner_component
269                        && binding.field == field.id
270                })
271                .map(|binding| binding.id.clone())
272                .collect::<Vec<_>>();
273            dirty.fields.insert(
274                tracking_id.clone(),
275                FieldDirtyTracking {
276                    id: tracking_id.clone(),
277                    plan: dirty_id.clone(),
278                    field: field.id.clone(),
279                    form: form.id.clone(),
280                    component: field.owner_component.clone(),
281                    initial_value: field.initial_value.clone(),
282                    initial_dirty: false,
283                    declaration_order: field.declaration_order,
284                    transitions: DirtyTransitionPlan {
285                        committed_write: DirtyTransitionCause::CommittedFieldValueWrite,
286                        reset_field: DirtyTransitionCause::ResetField,
287                        reset_form: DirtyTransitionCause::ResetForm,
288                        compares_with_initial_value: true,
289                        may_handoff_to_direct_validation_dependencies: true,
290                    },
291                    provenance: field.provenance.clone(),
292                    initializer_provenance: field.initializer_provenance.clone(),
293                    boundary: ExecutionBoundary::Client,
294                },
295            );
296            touched.fields.insert(
297                tracking_id.clone(),
298                FieldTouchedTracking {
299                    id: tracking_id,
300                    plan: touched_id.clone(),
301                    field: field.id.clone(),
302                    form: form.id.clone(),
303                    component: field.owner_component.clone(),
304                    initial_touched: false,
305                    declaration_order: field.declaration_order,
306                    blur_bindings: blur_bindings.clone(),
307                    transitions: TouchedTransitionPlan {
308                        blur: (!blur_bindings.is_empty())
309                            .then_some(TouchedTransitionCause::BoundControlBlur),
310                        reset_field: TouchedTransitionCause::ResetField,
311                        reset_form: TouchedTransitionCause::ResetForm,
312                        may_handoff_blur_to_target_validation: !blur_bindings.is_empty(),
313                    },
314                    provenance: field.provenance.clone(),
315                    boundary: ExecutionBoundary::Client,
316                },
317            );
318        }
319
320        dirty.plans.insert(
321            dirty_id.clone(),
322            DirtyTrackingPlan {
323                id: dirty_id,
324                form: form.id.clone(),
325                component: form
326                    .owner
327                    .entity_id()
328                    .expect("valid Form has component owner")
329                    .clone(),
330                fields: tracking_ids.clone(),
331                validation: FormTrackingValidation::default(),
332            },
333        );
334        touched.plans.insert(
335            touched_id.clone(),
336            TouchedTrackingPlan {
337                id: touched_id,
338                form: form.id.clone(),
339                component: form
340                    .owner
341                    .entity_id()
342                    .expect("valid Form has component owner")
343                    .clone(),
344                fields: tracking_ids,
345                validation: FormTrackingValidation::default(),
346            },
347        );
348    }
349
350    dirty.validation = validate_dirty_tracking_graph(&dirty, forms, fields, bindings, ownership);
351    touched.validation =
352        validate_touched_tracking_graph(&touched, forms, fields, bindings, ownership);
353    for plan in dirty.plans.values_mut() {
354        plan.validation = dirty.validation.clone();
355    }
356    for plan in touched.plans.values_mut() {
357        plan.validation = touched.validation.clone();
358    }
359    FormTrackingProducts { dirty, touched }
360}
361
362#[must_use]
363#[allow(clippy::too_many_lines)]
364/// # Panics
365///
366/// Panics when a supposedly valid I2 Form lacks its required canonical
367/// Component owner. That is a violated pre-I8 invariant.
368pub fn validate_dirty_tracking_graph(
369    graph: &DirtyTrackingGraph,
370    forms: &BTreeMap<FormId, FormEntity>,
371    fields: &BTreeMap<FieldId, FormFieldEntity>,
372    _bindings: &BTreeMap<FieldBindingId, FormFieldBinding>,
373    ownership: &FormOwnershipGraph,
374) -> FormTrackingValidation {
375    let mut validation = FormTrackingValidation::default();
376    for form in forms.values() {
377        let plan_id = DirtyTrackingPlanId::for_form(&form.id);
378        let Some(plan) = graph.plans.get(&plan_id) else {
379            push(
380                &mut validation,
381                FormTrackingIntegrityKind::MissingForm,
382                format!("Form `{}` has no dirty tracking plan", form.id.as_str()),
383            );
384            continue;
385        };
386        if plan.id != plan_id
387            || plan.form != form.id
388            || plan.component
389                != *form
390                    .owner
391                    .entity_id()
392                    .expect("valid Form has component owner")
393        {
394            push(
395                &mut validation,
396                FormTrackingIntegrityKind::PlanIdentityMismatch,
397                format!(
398                    "dirty tracking plan for `{}` has non-canonical identity or owner",
399                    form.id.as_str()
400                ),
401            );
402        }
403        if ownership.component_of_form(&form.id) != form.owner.entity_id() {
404            push(
405                &mut validation,
406                FormTrackingIntegrityKind::FieldFormMismatch,
407                format!(
408                    "dirty tracking Form `{}` is absent from canonical I5 ownership",
409                    form.id.as_str()
410                ),
411            );
412        }
413        let expected = ordered_fields(fields, &form.id)
414            .into_iter()
415            .map(|field| FieldTrackingId::for_field(&field.id))
416            .collect::<Vec<_>>();
417        if plan.fields != expected {
418            push(
419                &mut validation,
420                FormTrackingIntegrityKind::NonCanonicalOrdering,
421                format!(
422                    "dirty tracking plan `{}` does not retain I3 field order",
423                    plan.id.as_str()
424                ),
425            );
426        }
427    }
428    for field in fields.values() {
429        let id = FieldTrackingId::for_field(&field.id);
430        let Some(record) = graph.fields.get(&id) else {
431            push(
432                &mut validation,
433                FormTrackingIntegrityKind::MissingTrackingRecord,
434                format!("Field `{}` has no dirty tracking record", field.id.as_str()),
435            );
436            continue;
437        };
438        if record.id != id || record.plan != DirtyTrackingPlanId::for_form(&field.owner_form) {
439            push(
440                &mut validation,
441                FormTrackingIntegrityKind::TrackingIdentityMismatch,
442                format!(
443                    "dirty tracking for `{}` has non-canonical identity",
444                    field.id.as_str()
445                ),
446            );
447        }
448        if record.field != field.id || record.form != field.owner_form {
449            push(
450                &mut validation,
451                FormTrackingIntegrityKind::FieldFormMismatch,
452                format!(
453                    "dirty tracking `{}` disagrees with I3 Field ownership",
454                    record.id.as_str()
455                ),
456            );
457        }
458        if record.component != field.owner_component {
459            push(
460                &mut validation,
461                FormTrackingIntegrityKind::ComponentMismatch,
462                format!(
463                    "dirty tracking `{}` disagrees with I3 Field Component",
464                    record.id.as_str()
465                ),
466            );
467        }
468        if ownership.component_of_field(&field.id) != Some(&field.owner_component) {
469            push(
470                &mut validation,
471                FormTrackingIntegrityKind::FieldFormMismatch,
472                format!(
473                    "dirty tracking `{}` is absent from canonical I5 Field ownership",
474                    record.id.as_str()
475                ),
476            );
477        }
478        if record.initial_value != field.initial_value || record.initial_dirty {
479            push(&mut validation, FormTrackingIntegrityKind::InitialValueMismatch, format!("dirty tracking `{}` does not retain the I3 initial value or clean initial state", record.id.as_str()));
480        }
481        if record.provenance != field.provenance
482            || record.initializer_provenance != field.initializer_provenance
483        {
484            push(
485                &mut validation,
486                FormTrackingIntegrityKind::MissingProvenance,
487                format!(
488                    "dirty tracking `{}` lacks canonical field provenance",
489                    record.id.as_str()
490                ),
491            );
492        }
493    }
494    for record in graph.fields.values() {
495        if !fields.contains_key(&record.field) {
496            push(
497                &mut validation,
498                FormTrackingIntegrityKind::MissingField,
499                format!(
500                    "dirty tracking `{}` references unknown Field",
501                    record.id.as_str()
502                ),
503            );
504        }
505    }
506    validation
507}
508
509#[must_use]
510#[allow(clippy::too_many_lines)]
511/// # Panics
512///
513/// Panics when a supposedly valid I2 Form lacks its required canonical
514/// Component owner. That is a violated pre-I8 invariant.
515pub fn validate_touched_tracking_graph(
516    graph: &TouchedTrackingGraph,
517    forms: &BTreeMap<FormId, FormEntity>,
518    fields: &BTreeMap<FieldId, FormFieldEntity>,
519    bindings: &BTreeMap<FieldBindingId, FormFieldBinding>,
520    ownership: &FormOwnershipGraph,
521) -> FormTrackingValidation {
522    let mut validation = FormTrackingValidation::default();
523    for form in forms.values() {
524        let plan_id = TouchedTrackingPlanId::for_form(&form.id);
525        let Some(plan) = graph.plans.get(&plan_id) else {
526            push(
527                &mut validation,
528                FormTrackingIntegrityKind::MissingForm,
529                format!("Form `{}` has no touched tracking plan", form.id.as_str()),
530            );
531            continue;
532        };
533        if plan.id != plan_id
534            || plan.form != form.id
535            || plan.component
536                != *form
537                    .owner
538                    .entity_id()
539                    .expect("valid Form has component owner")
540        {
541            push(
542                &mut validation,
543                FormTrackingIntegrityKind::PlanIdentityMismatch,
544                format!(
545                    "touched tracking plan for `{}` has non-canonical identity or owner",
546                    form.id.as_str()
547                ),
548            );
549        }
550        if ownership.component_of_form(&form.id) != form.owner.entity_id() {
551            push(
552                &mut validation,
553                FormTrackingIntegrityKind::FieldFormMismatch,
554                format!(
555                    "touched tracking Form `{}` is absent from canonical I5 ownership",
556                    form.id.as_str()
557                ),
558            );
559        }
560        let expected = ordered_fields(fields, &form.id)
561            .into_iter()
562            .map(|field| FieldTrackingId::for_field(&field.id))
563            .collect::<Vec<_>>();
564        if plan.fields != expected {
565            push(
566                &mut validation,
567                FormTrackingIntegrityKind::NonCanonicalOrdering,
568                format!(
569                    "touched tracking plan `{}` does not retain I3 field order",
570                    plan.id.as_str()
571                ),
572            );
573        }
574    }
575    for field in fields.values() {
576        let id = FieldTrackingId::for_field(&field.id);
577        let Some(record) = graph.fields.get(&id) else {
578            push(
579                &mut validation,
580                FormTrackingIntegrityKind::MissingTrackingRecord,
581                format!(
582                    "Field `{}` has no touched tracking record",
583                    field.id.as_str()
584                ),
585            );
586            continue;
587        };
588        if record.id != id || record.plan != TouchedTrackingPlanId::for_form(&field.owner_form) {
589            push(
590                &mut validation,
591                FormTrackingIntegrityKind::TrackingIdentityMismatch,
592                format!(
593                    "touched tracking for `{}` has non-canonical identity",
594                    field.id.as_str()
595                ),
596            );
597        }
598        if record.field != field.id || record.form != field.owner_form {
599            push(
600                &mut validation,
601                FormTrackingIntegrityKind::FieldFormMismatch,
602                format!(
603                    "touched tracking `{}` disagrees with I3 Field ownership",
604                    record.id.as_str()
605                ),
606            );
607        }
608        if record.component != field.owner_component || record.initial_touched {
609            push(
610                &mut validation,
611                FormTrackingIntegrityKind::ComponentMismatch,
612                format!(
613                    "touched tracking `{}` disagrees with I3 owner or clean initial state",
614                    record.id.as_str()
615                ),
616            );
617        }
618        if ownership.component_of_field(&field.id) != Some(&field.owner_component) {
619            push(
620                &mut validation,
621                FormTrackingIntegrityKind::FieldFormMismatch,
622                format!(
623                    "touched tracking `{}` is absent from canonical I5 Field ownership",
624                    record.id.as_str()
625                ),
626            );
627        }
628        if record.provenance != field.provenance {
629            push(
630                &mut validation,
631                FormTrackingIntegrityKind::MissingProvenance,
632                format!(
633                    "touched tracking `{}` lacks canonical field provenance",
634                    record.id.as_str()
635                ),
636            );
637        }
638    }
639    for tracking in graph.fields.values() {
640        let expected = ordered_bindings_for_field(bindings, &tracking.field)
641            .into_iter()
642            .filter(|binding| {
643                binding.form == tracking.form && binding.component == tracking.component
644            })
645            .map(|binding| binding.id.clone())
646            .collect::<Vec<_>>();
647        if tracking.blur_bindings != expected {
648            push(
649                &mut validation,
650                FormTrackingIntegrityKind::BindingMismatch,
651                format!(
652                    "touched tracking `{}` does not retain the canonical bound-control blur set",
653                    tracking.id.as_str()
654                ),
655            );
656        }
657        if tracking
658            .blur_bindings
659            .windows(2)
660            .any(|pair| pair[0] >= pair[1])
661            && tracking.blur_bindings.len() > 1
662        {
663            push(
664                &mut validation,
665                FormTrackingIntegrityKind::NonCanonicalOrdering,
666                format!(
667                    "touched tracking `{}` blur bindings are not canonical",
668                    tracking.id.as_str()
669                ),
670            );
671        }
672    }
673    for record in graph.fields.values() {
674        if !fields.contains_key(&record.field) {
675            push(
676                &mut validation,
677                FormTrackingIntegrityKind::MissingField,
678                format!(
679                    "touched tracking `{}` references unknown Field",
680                    record.id.as_str()
681                ),
682            );
683        }
684    }
685    validation
686}
687
688fn ordered_fields<'a>(
689    fields: &'a BTreeMap<FieldId, FormFieldEntity>,
690    form: &FormId,
691) -> Vec<&'a FormFieldEntity> {
692    let mut result = fields
693        .values()
694        .filter(|field| &field.owner_form == form)
695        .collect::<Vec<_>>();
696    result.sort_by(|left, right| {
697        (left.declaration_order, &left.id).cmp(&(right.declaration_order, &right.id))
698    });
699    result
700}
701
702fn ordered_bindings_for_field<'a>(
703    bindings: &'a BTreeMap<FieldBindingId, FormFieldBinding>,
704    field: &FieldId,
705) -> Vec<&'a FormFieldBinding> {
706    let mut result = bindings
707        .values()
708        .filter(|binding| &binding.field == field)
709        .collect::<Vec<_>>();
710    result.sort_by(|left, right| {
711        (left.authored_order, &left.id).cmp(&(right.authored_order, &right.id))
712    });
713    result
714}
715
716fn push(validation: &mut FormTrackingValidation, kind: FormTrackingIntegrityKind, message: String) {
717    validation
718        .diagnostics
719        .push(FormTrackingIntegrityDiagnostic {
720            code: kind.code().to_string(),
721            kind,
722            message,
723        });
724    validation.diagnostics.sort_by(|left, right| {
725        (left.code.as_str(), left.message.as_str())
726            .cmp(&(right.code.as_str(), right.message.as_str()))
727    });
728    validation.diagnostics.dedup();
729    validation.is_valid = false;
730}
731
732#[cfg(test)]
733mod tests {
734    use std::collections::BTreeMap;
735
736    use crate::{
737        build_application_semantic_model, DirtyTrackingPlanId, FieldId, FormId, SerializableValue,
738        TouchedTrackingPlanId,
739    };
740
741    fn model() -> crate::ApplicationSemanticModel {
742        let parsed = presolve_parser::parse_file(
743            "src/Profile.tsx",
744            r#"
745@component("profile-editor")
746class ProfileEditor {
747  @form() profile!: Form;
748  @field(this.profile) name = "Austin";
749  @field(this.profile) tags: string[] = ["compiler"];
750  @field(this.profile) contact: "email" | "phone" = "email";
751  @field(this.profile) unbound = false;
752  render() {
753    return <div>
754      <input field={this.name} />
755      <input type="radio" value="email" field={this.contact} />
756      <input type="radio" value="phone" field={this.contact} />
757    </div>;
758  }
759}
760"#,
761        );
762        build_application_semantic_model(&parsed)
763    }
764
765    #[test]
766    fn plans_every_field_and_records_dirty_and_blur_facts() {
767        let model = model();
768        assert!(model.form_tracking.dirty.validation.is_valid);
769        assert!(model.form_tracking.touched.validation.is_valid);
770        let form = FormId::for_owner(&model.components[0].id, "profile");
771        assert!(model
772            .form_tracking
773            .dirty
774            .plans
775            .contains_key(&DirtyTrackingPlanId::for_form(&form)));
776        assert!(model
777            .form_tracking
778            .touched
779            .plans
780            .contains_key(&TouchedTrackingPlanId::for_form(&form)));
781
782        let name = FieldId::for_form(&form, "name");
783        let tags = FieldId::for_form(&form, "tags");
784        let contact = FieldId::for_form(&form, "contact");
785        let unbound = FieldId::for_form(&form, "unbound");
786        assert_eq!(
787            model.form_tracking.dirty.dirty_after_committed_write(
788                &name,
789                &SerializableValue::String("Austin".to_string())
790            ),
791            Some(false)
792        );
793        assert_eq!(
794            model
795                .form_tracking
796                .dirty
797                .dirty_after_committed_write(&name, &SerializableValue::String("Ada".to_string())),
798            Some(true)
799        );
800        assert_eq!(
801            model.form_tracking.dirty.dirty_after_committed_write(
802                &tags,
803                &SerializableValue::Array(vec![SerializableValue::String("compiler".to_string())])
804            ),
805            Some(false)
806        );
807        assert_eq!(model.form_tracking.touched.blur_bindings(&contact).len(), 2);
808        assert!(model
809            .form_tracking
810            .touched
811            .blur_bindings(&unbound)
812            .is_empty());
813    }
814
815    #[test]
816    fn structural_equality_is_recursive_and_key_order_independent() {
817        let mut left = BTreeMap::new();
818        left.insert(
819            "city".to_string(),
820            SerializableValue::String("Austin".to_string()),
821        );
822        left.insert(
823            "zip".to_string(),
824            SerializableValue::String("78701".to_string()),
825        );
826        let mut right = BTreeMap::new();
827        right.insert(
828            "zip".to_string(),
829            SerializableValue::String("78701".to_string()),
830        );
831        right.insert(
832            "city".to_string(),
833            SerializableValue::String("Austin".to_string()),
834        );
835        assert!(super::structurally_equal_serializable_values(
836            &SerializableValue::Object(left),
837            &SerializableValue::Object(right)
838        ));
839    }
840
841    #[test]
842    fn malformed_tracking_is_reported_without_reconstructing_it() {
843        let model = model();
844        let mut dirty = model.form_tracking.dirty.clone();
845        let record = dirty.fields.values_mut().next().expect("dirty record");
846        record.initial_dirty = true;
847        let validation = super::validate_dirty_tracking_graph(
848            &dirty,
849            &model.forms,
850            &model.form_fields,
851            &model.form_field_bindings,
852            &model.form_ownership,
853        );
854        assert!(!validation.is_valid);
855        assert!(validation
856            .diagnostics
857            .iter()
858            .any(|diagnostic| diagnostic.kind
859                == super::FormTrackingIntegrityKind::InitialValueMismatch));
860    }
861
862    #[test]
863    fn remains_internal_and_does_not_change_the_frozen_public_graph() {
864        let model = model();
865        let graph = crate::build_semantic_graph(&model);
866        let json = crate::semantic_graph_json(&graph);
867        assert!(!json.contains("dirty-plan"));
868        assert!(!json.contains("touched-plan"));
869        assert!(!json.contains("/tracking"));
870    }
871}