Skip to main content

sim_lib_skill/
fixture.rs

1use std::{
2    collections::BTreeMap,
3    sync::{
4        Mutex,
5        atomic::{AtomicUsize, Ordering},
6    },
7};
8
9use sim_kernel::{Cx, Error, Expr, Result, Symbol, Value};
10
11use crate::{SkillCard, SkillEventSink, SkillTransport};
12
13/// Deterministic behavior a [`FixtureTransport`] runs for an operation.
14#[derive(Clone, Debug)]
15pub enum FixtureBehavior {
16    /// Returns the call arguments unchanged.
17    EchoArgs,
18    /// Returns the numeric sum of the argument list.
19    SumNumbers,
20    /// Returns a fixed string regardless of arguments.
21    ConstantString(String),
22}
23
24/// In-memory skill transport for tests and examples.
25///
26/// Each registered operation maps to a [`FixtureBehavior`]; calls are counted
27/// so tests can assert on dispatch.
28pub struct FixtureTransport {
29    id: String,
30    handlers: Mutex<BTreeMap<String, FixtureBehavior>>,
31    calls: AtomicUsize,
32}
33
34impl FixtureTransport {
35    /// Creates an empty fixture transport with the given `id`.
36    pub fn new(id: impl Into<String>) -> Self {
37        Self {
38            id: id.into(),
39            handlers: Mutex::new(BTreeMap::new()),
40            calls: AtomicUsize::new(0),
41        }
42    }
43
44    /// Registers `behavior` for `operation`, replacing any existing handler.
45    pub fn insert(&self, operation: impl Into<String>, behavior: FixtureBehavior) -> Result<()> {
46        self.handlers
47            .lock()
48            .map_err(|_| Error::PoisonedLock("fixture skill transport"))?
49            .insert(operation.into(), behavior);
50        Ok(())
51    }
52
53    /// Returns the number of calls dispatched through this transport.
54    pub fn call_count(&self) -> usize {
55        self.calls.load(Ordering::SeqCst)
56    }
57}
58
59impl SkillTransport for FixtureTransport {
60    fn id(&self) -> &str {
61        &self.id
62    }
63
64    fn kind(&self) -> &str {
65        "fixture"
66    }
67
68    fn discover(&self, _cx: &mut Cx) -> Result<Vec<SkillCard>> {
69        Ok(Vec::new())
70    }
71
72    fn call(
73        &self,
74        cx: &mut Cx,
75        card: &SkillCard,
76        args: Value,
77        _events: Option<&mut dyn SkillEventSink>,
78    ) -> Result<Value> {
79        let behavior = self
80            .handlers
81            .lock()
82            .map_err(|_| Error::PoisonedLock("fixture skill transport"))?
83            .get(&card.operation)
84            .cloned()
85            .ok_or_else(|| {
86                Error::Eval(format!(
87                    "fixture transport {} has no operation {}",
88                    self.id, card.operation
89                ))
90            })?;
91        self.calls.fetch_add(1, Ordering::SeqCst);
92        match behavior {
93            FixtureBehavior::EchoArgs => Ok(args),
94            FixtureBehavior::SumNumbers => sum_number_args(cx, args),
95            FixtureBehavior::ConstantString(text) => cx.factory().string(text),
96        }
97    }
98
99    fn health(&self, cx: &mut Cx) -> Result<Value> {
100        cx.factory().table(vec![
101            (
102                Symbol::new("kind"),
103                cx.factory().symbol(Symbol::new("skill/health"))?,
104            ),
105            (Symbol::new("id"), cx.factory().string(self.id.clone())?),
106            (
107                Symbol::new("calls"),
108                cx.factory().string(self.call_count().to_string())?,
109            ),
110        ])
111    }
112}
113
114fn sum_number_args(cx: &mut Cx, args: Value) -> Result<Value> {
115    let Expr::List(items) = args.object().as_expr(cx)? else {
116        return Err(Error::TypeMismatch {
117            expected: "argument list",
118            found: "non-list",
119        });
120    };
121    let mut sum = 0.0;
122    for item in items {
123        let Expr::Number(number) = item else {
124            return Err(Error::TypeMismatch {
125                expected: "number",
126                found: "non-number",
127            });
128        };
129        sum += number
130            .canonical
131            .parse::<f64>()
132            .map_err(|err| Error::Eval(format!("invalid number literal: {err}")))?;
133    }
134    let canonical = if sum.fract() == 0.0 {
135        format!("{}", sum as i64)
136    } else {
137        sum.to_string()
138    };
139    cx.factory()
140        .number_literal(Symbol::qualified("numbers", "f64"), canonical)
141}