Skip to main content

traverse_embedder/
test_double.rs

1//! Deterministic in-memory test double for the embedder boundary
2//! (spec 068 FR-006).
3
4use crate::{
5    CompatibleLifecycleOutcome, CompatibleStartOutcome, EmbedderCore, EmbedderError,
6    EmbedderErrorCode, EventCallback, InstanceState, ShutdownOutcome, SubmitOutcome, SubmitStatus,
7    TraverseEmbedderApi,
8};
9use serde_json::{Value, json};
10use std::collections::BTreeMap;
11
12/// A scripted submit result for one target id.
13#[derive(Debug, Clone)]
14enum ScriptedResult {
15    Output(Value),
16    Error { code: String, message: String },
17}
18
19/// Deterministic test double implementing [`TraverseEmbedderApi`].
20///
21/// The double shares the production embedder's event envelope, identifier
22/// scheme, compatible-capability lifecycle, and shutdown semantics through
23/// the same internal core; only capability execution is replaced with
24/// scripted results. It contains no business logic and never replaces
25/// runtime-owned behavior in production code paths.
26pub struct EmbedderTestDouble {
27    core: EmbedderCore,
28    scripted: BTreeMap<String, ScriptedResult>,
29}
30
31impl EmbedderTestDouble {
32    /// Creates a double with the given identity, no scripted targets, and
33    /// no compatible capabilities.
34    #[must_use]
35    pub fn new(
36        workspace_id: impl Into<String>,
37        app_id: impl Into<String>,
38        app_version: impl Into<String>,
39        platform: impl Into<String>,
40    ) -> Self {
41        Self {
42            core: EmbedderCore::new(
43                workspace_id.into(),
44                app_id.into(),
45                app_version.into(),
46                platform.into(),
47                BTreeMap::new(),
48            ),
49            scripted: BTreeMap::new(),
50        }
51    }
52
53    /// Scripts `submit(target_id, _)` to succeed with `output`.
54    #[must_use]
55    pub fn with_target_output(mut self, target_id: impl Into<String>, output: Value) -> Self {
56        self.scripted
57            .insert(target_id.into(), ScriptedResult::Output(output));
58        self
59    }
60
61    /// Scripts `submit(target_id, _)` to fail with a runtime-shaped error.
62    #[must_use]
63    pub fn with_target_error(
64        mut self,
65        target_id: impl Into<String>,
66        code: impl Into<String>,
67        message: impl Into<String>,
68    ) -> Self {
69        self.scripted.insert(
70            target_id.into(),
71            ScriptedResult::Error {
72                code: code.into(),
73                message: message.into(),
74            },
75        );
76        self
77    }
78
79    /// Declares a compatible-mode capability with a platform allowlist.
80    #[must_use]
81    pub fn with_compatible_target(
82        mut self,
83        capability_id: impl Into<String>,
84        platforms: Vec<String>,
85    ) -> Self {
86        self.core
87            .compatible_targets
88            .insert(capability_id.into(), platforms);
89        self
90    }
91}
92
93impl TraverseEmbedderApi for EmbedderTestDouble {
94    fn submit(&mut self, target_id: &str, input: &Value) -> SubmitOutcome {
95        let _ = input;
96        if self.core.stopped {
97            let error = crate::runtime_stopped_error();
98            return self.core.rejected_submit(target_id, error);
99        }
100        let Some(result) = self.scripted.get(target_id).cloned() else {
101            let error = EmbedderError::new(
102                EmbedderErrorCode::TargetNotFound,
103                format!("'{target_id}' is neither a bundled workflow nor a bundled capability"),
104            );
105            return self.core.rejected_submit(target_id, error);
106        };
107
108        let session_id = self.core.next_session_id();
109        let request_id = self.core.next_request_id();
110        let execution_id = format!("exec_{request_id}");
111        self.core.emit(
112            "capability_invoked",
113            Some(&session_id),
114            json!({
115                "execution_id": execution_id,
116                "capability_id": target_id,
117                "capability_version": "1.0.0",
118            }),
119        );
120        match result {
121            ScriptedResult::Output(output) => {
122                self.core.emit(
123                    "capability_result",
124                    Some(&session_id),
125                    json!({
126                        "execution_id": execution_id,
127                        "capability_id": target_id,
128                        "status": "completed",
129                        "output": output,
130                    }),
131                );
132            }
133            ScriptedResult::Error { code, message } => {
134                self.core.emit(
135                    "error",
136                    Some(&session_id),
137                    json!({
138                        "execution_id": execution_id,
139                        "capability_id": target_id,
140                        "status": "error",
141                        "error": { "code": code, "message": message, "details": {} },
142                    }),
143                );
144            }
145        }
146        SubmitOutcome {
147            session_id: Some(session_id),
148            status: SubmitStatus::Accepted,
149            error: None,
150        }
151    }
152
153    fn subscribe(&mut self, callback: EventCallback) {
154        self.core.subscribe(callback);
155    }
156
157    fn start_compatible(&mut self, capability_id: &str, input: &Value) -> CompatibleStartOutcome {
158        self.core.start_compatible(capability_id, input)
159    }
160
161    fn stop_compatible(
162        &mut self,
163        capability_id: &str,
164        instance_id: Option<&str>,
165    ) -> CompatibleLifecycleOutcome {
166        self.core
167            .transition_compatible(capability_id, instance_id, InstanceState::Stopped)
168    }
169
170    fn kill_compatible(
171        &mut self,
172        capability_id: &str,
173        instance_id: Option<&str>,
174    ) -> CompatibleLifecycleOutcome {
175        self.core
176            .transition_compatible(capability_id, instance_id, InstanceState::Killed)
177    }
178
179    fn shutdown(&mut self) -> ShutdownOutcome {
180        self.core.shutdown()
181    }
182
183    fn release_evidence(&self) -> Value {
184        self.core.evidence("test-double", json!([]))
185    }
186}