Skip to main content

victauri_core/
verification.rs

1//! Cross-boundary verification, ghost command detection, IPC integrity
2//! checks, and semantic test assertions.
3
4use std::fmt;
5
6use crate::event::{EventLog, IpcResult};
7use crate::registry::CommandRegistry;
8use crate::types::{Divergence, DivergenceSeverity, VerificationResult};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Tolerance for floating-point comparisons in severity classification.
13const FLOAT_EPSILON: f64 = 1e-9;
14
15// ── Cross-boundary state verification ───────────────────────────────────────
16
17/// Compares frontend and backend state trees, returning all divergences found.
18///
19/// ```
20/// use victauri_core::verification::verify_state;
21/// use serde_json::json;
22///
23/// let result = verify_state(json!({"title": "App"}), json!({"title": "App"}));
24/// assert!(result.passed);
25/// assert!(result.divergences.is_empty());
26/// ```
27#[must_use]
28pub fn verify_state(
29    frontend_state: serde_json::Value,
30    backend_state: serde_json::Value,
31) -> VerificationResult {
32    let mut divergences = Vec::new();
33    compare_values("", &frontend_state, &backend_state, &mut divergences, 0);
34    let passed = divergences.is_empty();
35    VerificationResult {
36        passed,
37        frontend_state,
38        backend_state,
39        divergences,
40    }
41}
42
43/// Maximum nesting depth `compare_values` will recurse through. `verify_state`
44/// takes a fully caller-controlled `backend_state`, so an unbounded recursion
45/// here is a stack-overflow (denial of service) on the in-process app. A depth this large is far
46/// beyond any legitimate UI/backend state shape; deeper structures are reported
47/// as a single bounded divergence instead of being walked.
48const MAX_COMPARE_DEPTH: usize = 128;
49
50fn compare_values(
51    path: &str,
52    frontend: &serde_json::Value,
53    backend: &serde_json::Value,
54    divergences: &mut Vec<Divergence>,
55    depth: usize,
56) {
57    if frontend == backend {
58        return;
59    }
60
61    if depth >= MAX_COMPARE_DEPTH {
62        divergences.push(Divergence {
63            path: if path.is_empty() {
64                "$".to_string()
65            } else {
66                path.to_string()
67            },
68            frontend_value: serde_json::Value::String(format!(
69                "<max compare depth {MAX_COMPARE_DEPTH} exceeded>"
70            )),
71            backend_value: serde_json::Value::Null,
72            severity: DivergenceSeverity::Warning,
73        });
74        return;
75    }
76
77    match (frontend, backend) {
78        (serde_json::Value::Object(f_map), serde_json::Value::Object(b_map)) => {
79            for (key, f_val) in f_map {
80                let child_path = if path.is_empty() {
81                    key.clone()
82                } else {
83                    format!("{path}.{key}")
84                };
85                match b_map.get(key) {
86                    Some(b_val) => {
87                        compare_values(&child_path, f_val, b_val, divergences, depth + 1);
88                    }
89                    None => divergences.push(Divergence {
90                        path: child_path,
91                        frontend_value: f_val.clone(),
92                        backend_value: serde_json::Value::Null,
93                        severity: DivergenceSeverity::Warning,
94                    }),
95                }
96            }
97            for key in b_map.keys() {
98                if !f_map.contains_key(key) {
99                    let child_path = if path.is_empty() {
100                        key.clone()
101                    } else {
102                        format!("{path}.{key}")
103                    };
104                    divergences.push(Divergence {
105                        path: child_path,
106                        frontend_value: serde_json::Value::Null,
107                        backend_value: b_map[key].clone(),
108                        severity: DivergenceSeverity::Warning,
109                    });
110                }
111            }
112        }
113        (serde_json::Value::Array(f_arr), serde_json::Value::Array(b_arr)) => {
114            let max_len = f_arr.len().max(b_arr.len());
115            for i in 0..max_len {
116                let child_path = if path.is_empty() {
117                    format!("[{i}]")
118                } else {
119                    format!("{path}[{i}]")
120                };
121                match (f_arr.get(i), b_arr.get(i)) {
122                    (Some(f_val), Some(b_val)) => {
123                        compare_values(&child_path, f_val, b_val, divergences, depth + 1);
124                    }
125                    (Some(f_val), None) => divergences.push(Divergence {
126                        path: child_path,
127                        frontend_value: f_val.clone(),
128                        backend_value: serde_json::Value::Null,
129                        severity: DivergenceSeverity::Warning,
130                    }),
131                    (None, Some(b_val)) => divergences.push(Divergence {
132                        path: child_path,
133                        frontend_value: serde_json::Value::Null,
134                        backend_value: b_val.clone(),
135                        severity: DivergenceSeverity::Warning,
136                    }),
137                    (None, None) => {}
138                }
139            }
140        }
141        _ => {
142            let severity = classify_severity(frontend, backend);
143            divergences.push(Divergence {
144                path: if path.is_empty() {
145                    "$".to_string()
146                } else {
147                    path.to_string()
148                },
149                frontend_value: frontend.clone(),
150                backend_value: backend.clone(),
151                severity,
152            });
153        }
154    }
155}
156
157fn classify_severity(
158    frontend: &serde_json::Value,
159    backend: &serde_json::Value,
160) -> DivergenceSeverity {
161    match (frontend, backend) {
162        (serde_json::Value::Null, _) | (_, serde_json::Value::Null) => DivergenceSeverity::Warning,
163        (serde_json::Value::Number(f), serde_json::Value::Number(b)) => {
164            match (f.as_f64(), b.as_f64()) {
165                (Some(fv), Some(bv)) if (fv - bv).abs() < FLOAT_EPSILON => DivergenceSeverity::Info,
166                _ => DivergenceSeverity::Error,
167            }
168        }
169        _ => DivergenceSeverity::Error,
170    }
171}
172
173// ── Ghost command detection ─────────────────────────────────────────────────
174
175/// Report of commands that exist on only one side of the *introspection registry*
176/// boundary (frontend IPC traffic vs. the registry Victauri knows about).
177///
178/// IMPORTANT — what `frontend_only` does and does **not** mean: it is the set of
179/// commands invoked from the frontend that are **absent from Victauri's
180/// introspection registry** (the `#[inspectable]` / `register_command_names` set).
181/// That registry is usually a *subset* of the app's real `tauri::generate_handler!`
182/// command set, so a `frontend_only` entry is **only** a true "ghost" (a call with
183/// no backend handler — a typo/dead command) when the registry mirrors the app's
184/// full handler list. If the registry is empty or sparse, `frontend_only` is
185/// dominated by perfectly real, merely-uninstrumented commands and must NOT be read
186/// as a bug list. The MCP/REST `detect_ghost_commands` tool annotates each report
187/// with a `reliability` signal for exactly this reason. `registry_only` is purely
188/// informational (registered handlers never invoked during the observation window).
189#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
190pub struct GhostCommandReport {
191    /// Commands invoked from the frontend but absent from Victauri's introspection
192    /// registry. A *candidate* ghost — only a real ghost if the registry is complete.
193    pub frontend_only: Vec<GhostCommand>,
194    /// Informational: registered in the backend but never invoked from the frontend.
195    pub registry_only: Vec<GhostCommand>,
196    /// Total unique commands observed from the frontend.
197    pub total_frontend_commands: usize,
198    /// Total commands registered in the backend registry.
199    pub total_registry_commands: usize,
200}
201
202/// A command that exists on only one side of the frontend/backend boundary.
203#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
204pub struct GhostCommand {
205    /// Command name as invoked or registered.
206    pub name: String,
207    /// Which side the command was found on.
208    pub source: GhostSource,
209    /// Optional description, if available from the registry.
210    pub description: Option<String>,
211}
212
213/// Indicates which side of the IPC boundary a ghost command was found on.
214#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
215#[non_exhaustive]
216pub enum GhostSource {
217    /// Command invoked from the frontend but absent from Victauri's introspection
218    /// registry. A candidate ghost — only a true missing-handler bug if the registry
219    /// mirrors the app's full `generate_handler!` command set.
220    FrontendOnly,
221    /// Command registered in the backend but never invoked from the frontend.
222    RegistryOnly,
223}
224
225impl fmt::Display for GhostSource {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        let s = match self {
228            Self::FrontendOnly => "frontend-only",
229            Self::RegistryOnly => "registry-only",
230        };
231        f.write_str(s)
232    }
233}
234
235impl fmt::Display for GhostCommand {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        write!(f, "{} ({})", self.name, self.source)
238    }
239}
240
241/// # Examples
242///
243/// ```
244/// use victauri_core::{GhostCommandReport, GhostCommand, GhostSource};
245///
246/// let report = GhostCommandReport {
247///     frontend_only: vec![
248///         GhostCommand {
249///             name: "delete".to_string(),
250///             source: GhostSource::FrontendOnly,
251///             description: None,
252///         },
253///     ],
254///     registry_only: vec![],
255///     total_frontend_commands: 3,
256///     total_registry_commands: 2,
257/// };
258/// assert_eq!(
259///     report.to_string(),
260///     "1 ghost command(s) (3 frontend, 2 registry)"
261/// );
262/// ```
263impl fmt::Display for GhostCommandReport {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        let n = self.frontend_only.len();
266        write!(
267            f,
268            "{n} ghost command(s) ({} frontend, {} registry)",
269            self.total_frontend_commands, self.total_registry_commands
270        )
271    }
272}
273
274/// # Examples
275///
276/// ```
277/// use victauri_core::verification::IpcIntegrityReport;
278///
279/// let healthy = IpcIntegrityReport {
280///     total_calls: 10,
281///     completed: 10,
282///     pending: 0,
283///     errored: 0,
284///     stale_calls: vec![],
285///     error_calls: vec![],
286///     healthy: true,
287/// };
288/// assert_eq!(
289///     healthy.to_string(),
290///     "IPC healthy: 10/10 completed"
291/// );
292///
293/// let unhealthy = IpcIntegrityReport {
294///     total_calls: 10,
295///     completed: 7,
296///     pending: 2,
297///     errored: 1,
298///     stale_calls: vec![],
299///     error_calls: vec![],
300///     healthy: false,
301/// };
302/// assert_eq!(
303///     unhealthy.to_string(),
304///     "IPC unhealthy: 0 stale, 1 errored of 10 calls"
305/// );
306/// ```
307impl fmt::Display for IpcIntegrityReport {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        if self.healthy {
310            write!(
311                f,
312                "IPC healthy: {}/{} completed",
313                self.completed, self.total_calls
314            )
315        } else {
316            let stale = self.stale_calls.len();
317            write!(
318                f,
319                "IPC unhealthy: {stale} stale, {} errored of {} calls",
320                self.errored, self.total_calls
321            )
322        }
323    }
324}
325
326/// Detects commands that exist on only one side of the IPC boundary (frontend vs registry).
327///
328/// # Examples
329///
330/// ```
331/// use victauri_core::verification::detect_ghost_commands;
332/// use victauri_core::{CommandRegistry, CommandInfo};
333///
334/// let registry = CommandRegistry::new();
335/// registry.register(CommandInfo::new("save").with_description("Save data"));
336///
337/// let frontend_cmds = vec!["save".to_string(), "delete".to_string()];
338/// let report = detect_ghost_commands(&frontend_cmds, &registry);
339/// // "delete" is frontend-only: invoked but absent from the registry. Here the
340/// // registry is the full command set, so it is a true ghost; with a partial
341/// // registry it would merely be uninstrumented. See `reliability` on the tool.
342/// assert_eq!(report.frontend_only.len(), 1);
343/// ```
344#[must_use]
345pub fn detect_ghost_commands(
346    frontend_commands: &[String],
347    registry: &CommandRegistry,
348) -> GhostCommandReport {
349    let registry_list = registry.list();
350    let registry_names: std::collections::HashSet<&str> =
351        registry_list.iter().map(|c| c.name.as_str()).collect();
352    let frontend_set: std::collections::HashSet<&str> = frontend_commands
353        .iter()
354        .map(std::string::String::as_str)
355        .collect();
356
357    let mut frontend_only = Vec::new();
358    let mut registry_only = Vec::new();
359
360    for name in &frontend_set {
361        if !registry_names.contains(name) {
362            frontend_only.push(GhostCommand {
363                name: name.to_string(),
364                source: GhostSource::FrontendOnly,
365                description: Some(
366                    "Invoked from the frontend but absent from Victauri's introspection \
367                     registry (#[inspectable]/register_command_names). NOT necessarily a \
368                     real ghost — only a missing-handler bug if the registry mirrors the \
369                     app's full command set."
370                        .to_string(),
371                ),
372            });
373        }
374    }
375
376    for cmd in &registry_list {
377        if !frontend_set.contains(cmd.name.as_str()) {
378            registry_only.push(GhostCommand {
379                name: cmd.name.clone(),
380                source: GhostSource::RegistryOnly,
381                description: cmd.description.clone(),
382            });
383        }
384    }
385
386    frontend_only.sort_by(|a, b| a.name.cmp(&b.name));
387    registry_only.sort_by(|a, b| a.name.cmp(&b.name));
388
389    GhostCommandReport {
390        frontend_only,
391        registry_only,
392        total_frontend_commands: frontend_set.len(),
393        total_registry_commands: registry_list.len(),
394    }
395}
396
397// ── IPC round-trip integrity ────────────────────────────────────────────────
398
399/// Summary of IPC round-trip health: completed, pending, errored, and stale calls.
400#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
401pub struct IpcIntegrityReport {
402    /// Total number of IPC calls analyzed.
403    pub total_calls: usize,
404    /// Calls that completed successfully.
405    pub completed: usize,
406    /// Calls still awaiting a response.
407    pub pending: usize,
408    /// Calls that returned an error.
409    pub errored: usize,
410    /// Pending calls that have exceeded the staleness threshold.
411    pub stale_calls: Vec<StaleCall>,
412    /// Calls that resulted in errors.
413    pub error_calls: Vec<ErrorCall>,
414    /// True if there are no stale or errored calls.
415    pub healthy: bool,
416}
417
418/// An IPC call that has been pending longer than the staleness threshold.
419#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
420pub struct StaleCall {
421    /// Unique call identifier.
422    pub id: String,
423    /// Name of the invoked command.
424    pub command: String,
425    /// When the call was initiated.
426    pub timestamp: DateTime<Utc>,
427    /// How long the call has been pending, in milliseconds.
428    pub age_ms: i64,
429    /// Webview that initiated the call.
430    pub webview_label: String,
431}
432
433/// An IPC call that returned an error result.
434#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
435pub struct ErrorCall {
436    /// Unique call identifier.
437    pub id: String,
438    /// Name of the invoked command.
439    pub command: String,
440    /// When the call was initiated.
441    pub timestamp: DateTime<Utc>,
442    /// Error message returned by the backend.
443    pub error: String,
444    /// Webview that initiated the call.
445    pub webview_label: String,
446}
447
448/// Analyzes the event log for IPC health, flagging stale and errored calls.
449///
450/// # Examples
451///
452/// ```
453/// use victauri_core::verification::check_ipc_integrity;
454/// use victauri_core::EventLog;
455///
456/// let log = EventLog::new(100);
457/// let report = check_ipc_integrity(&log, 5000);
458/// assert!(report.healthy);
459/// assert_eq!(report.total_calls, 0);
460/// ```
461#[must_use]
462pub fn check_ipc_integrity(event_log: &EventLog, stale_threshold_ms: i64) -> IpcIntegrityReport {
463    let now = Utc::now();
464    let calls = event_log.ipc_calls();
465    let total_calls = calls.len();
466    let mut completed = 0usize;
467    let mut pending = 0usize;
468    let mut errored = 0usize;
469    let mut stale_calls = Vec::new();
470    let mut error_calls = Vec::new();
471
472    for call in &calls {
473        match &call.result {
474            IpcResult::Ok(_) => completed += 1,
475            IpcResult::Pending => {
476                pending += 1;
477                let age_ms = (now - call.timestamp).num_milliseconds();
478                if age_ms >= stale_threshold_ms {
479                    stale_calls.push(StaleCall {
480                        id: call.id.clone(),
481                        command: call.command.clone(),
482                        timestamp: call.timestamp,
483                        age_ms,
484                        webview_label: call.webview_label.clone(),
485                    });
486                }
487            }
488            IpcResult::Err(e) => {
489                errored += 1;
490                error_calls.push(ErrorCall {
491                    id: call.id.clone(),
492                    command: call.command.clone(),
493                    timestamp: call.timestamp,
494                    error: e.clone(),
495                    webview_label: call.webview_label.clone(),
496                });
497            }
498        }
499    }
500
501    let healthy = stale_calls.is_empty() && errored == 0;
502
503    IpcIntegrityReport {
504        total_calls,
505        completed,
506        pending,
507        errored,
508        stale_calls,
509        error_calls,
510        healthy,
511    }
512}
513
514// ── Semantic test assertions ────────────────────────────────────────────────
515
516/// Recognized assertion condition operators for [`evaluate_assertion`].
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
519#[serde(rename_all = "snake_case")]
520#[non_exhaustive]
521pub enum AssertionCondition {
522    /// Actual value must equal the expected value.
523    Equals,
524    /// Actual value must not equal the expected value.
525    NotEquals,
526    /// String must contain substring, or array must contain element.
527    Contains,
528    /// Numeric actual must be greater than expected.
529    GreaterThan,
530    /// Numeric actual must be less than expected.
531    LessThan,
532    /// Value must be truthy (non-null, non-false, non-empty, non-zero).
533    Truthy,
534    /// Value must be falsy (null, false, empty string, zero).
535    Falsy,
536    /// Value must not be null.
537    Exists,
538    /// Value must be of the specified JSON type (string, number, boolean, array, object, null).
539    TypeIs,
540}
541
542impl std::str::FromStr for AssertionCondition {
543    type Err = crate::error::VictauriError;
544
545    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
546        match s {
547            "equals" => Ok(Self::Equals),
548            "not_equals" => Ok(Self::NotEquals),
549            "contains" => Ok(Self::Contains),
550            "greater_than" => Ok(Self::GreaterThan),
551            "less_than" => Ok(Self::LessThan),
552            "truthy" => Ok(Self::Truthy),
553            "falsy" => Ok(Self::Falsy),
554            "exists" => Ok(Self::Exists),
555            "type_is" => Ok(Self::TypeIs),
556            other => Err(crate::error::VictauriError::UnknownCondition {
557                condition: other.to_string(),
558            }),
559        }
560    }
561}
562
563impl std::fmt::Display for AssertionCondition {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        let s = match self {
566            Self::Equals => "equals",
567            Self::NotEquals => "not_equals",
568            Self::Contains => "contains",
569            Self::GreaterThan => "greater_than",
570            Self::LessThan => "less_than",
571            Self::Truthy => "truthy",
572            Self::Falsy => "falsy",
573            Self::Exists => "exists",
574            Self::TypeIs => "type_is",
575        };
576        f.write_str(s)
577    }
578}
579
580/// A declarative assertion to evaluate against a runtime value (e.g. "equals", "truthy").
581#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
582pub struct SemanticAssertion {
583    /// Human-readable label describing what is being asserted.
584    pub label: String,
585    /// Condition operator to evaluate.
586    pub condition: AssertionCondition,
587    /// Expected value to compare against (interpretation depends on condition).
588    pub expected: serde_json::Value,
589}
590
591/// Outcome of evaluating a semantic assertion against an actual value.
592#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
593pub struct AssertionResult {
594    /// Label from the original assertion.
595    pub label: String,
596    /// Whether the assertion passed.
597    pub passed: bool,
598    /// The actual value that was evaluated.
599    pub actual: serde_json::Value,
600    /// The expected value from the assertion.
601    pub expected: serde_json::Value,
602    /// Failure message explaining why the assertion failed, if it did.
603    pub message: Option<String>,
604}
605
606fn coerce_f64(v: &serde_json::Value) -> Option<f64> {
607    v.as_f64()
608        .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
609}
610
611fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
612    if a == b {
613        return true;
614    }
615    if let (Some(na), Some(nb)) = (coerce_f64(a), coerce_f64(b)) {
616        (na - nb).abs() < f64::EPSILON
617    } else {
618        let sa = a.as_str().map_or_else(|| a.to_string(), str::to_owned);
619        let sb = b.as_str().map_or_else(|| b.to_string(), str::to_owned);
620        sa == sb
621    }
622}
623
624/// Evaluates a semantic assertion against an actual runtime value.
625///
626/// ```
627/// use victauri_core::verification::{evaluate_assertion, AssertionCondition, SemanticAssertion};
628/// use serde_json::json;
629///
630/// let assertion = SemanticAssertion {
631///     label: "check count".to_string(),
632///     condition: AssertionCondition::Equals,
633///     expected: json!(42),
634/// };
635/// let result = evaluate_assertion(json!(42), &assertion);
636/// assert!(result.passed);
637/// ```
638#[must_use]
639pub fn evaluate_assertion(
640    actual: serde_json::Value,
641    assertion: &SemanticAssertion,
642) -> AssertionResult {
643    let passed = match assertion.condition {
644        AssertionCondition::Equals => values_equal(&actual, &assertion.expected),
645        AssertionCondition::NotEquals => !values_equal(&actual, &assertion.expected),
646        AssertionCondition::Contains => match (&actual, &assertion.expected) {
647            (serde_json::Value::String(a), serde_json::Value::String(e)) => a.contains(e.as_str()),
648            (serde_json::Value::Array(arr), val) => arr.contains(val),
649            _ => false,
650        },
651        AssertionCondition::GreaterThan => {
652            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
653                (Some(a), Some(e)) => a > e,
654                _ => false,
655            }
656        }
657        AssertionCondition::LessThan => {
658            match (coerce_f64(&actual), coerce_f64(&assertion.expected)) {
659                (Some(a), Some(e)) => a < e,
660                _ => false,
661            }
662        }
663        AssertionCondition::Truthy => match &actual {
664            serde_json::Value::Null | serde_json::Value::Bool(false) => false,
665            serde_json::Value::String(s) => !s.is_empty(),
666            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
667            _ => true,
668        },
669        AssertionCondition::Falsy => match &actual {
670            serde_json::Value::Null | serde_json::Value::Bool(false) => true,
671            serde_json::Value::String(s) => s.is_empty(),
672            serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) == 0.0,
673            _ => false,
674        },
675        AssertionCondition::Exists => actual != serde_json::Value::Null,
676        AssertionCondition::TypeIs => {
677            let type_name = assertion.expected.as_str().unwrap_or("");
678            match type_name {
679                "string" => actual.is_string(),
680                "number" => actual.is_number(),
681                "boolean" => actual.is_boolean(),
682                "array" => actual.is_array(),
683                "object" => actual.is_object(),
684                "null" => actual.is_null(),
685                _ => false,
686            }
687        }
688    };
689
690    let message = if passed {
691        None
692    } else {
693        Some(format!(
694            "Assertion '{}' failed: expected {} {:?}, got {:?}",
695            assertion.label, assertion.condition, assertion.expected, actual
696        ))
697    };
698
699    AssertionResult {
700        label: assertion.label.clone(),
701        passed,
702        actual,
703        expected: assertion.expected.clone(),
704        message,
705    }
706}