use mingli_contract::{effective_school_id, intents, CastingEngine, IntentSpec, LeafOutput, Moment, Query, QueryKind};
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
pub type Leaves = [Box<dyn CastingEngine>];
#[cfg(not(target_arch = "wasm32"))]
fn map_leaves<T: Send>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T + Send + Sync) -> Vec<T> {
reg.par_iter().map(f).collect()
}
#[cfg(target_arch = "wasm32")]
fn map_leaves<T>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T) -> Vec<T> {
reg.iter().map(f).collect()
}
#[must_use]
pub fn cast_all(reg: &Leaves, q: &Query) -> BTreeMap<String, Value> {
let m = shared_moment(q);
map_leaves(reg, |e| (e.id().to_string(), e.cast(&m, q))).into_iter().collect()
}
#[must_use]
pub fn cast_one(reg: &Leaves, id: &str, q: &Query) -> Option<LeafOutput> {
let e = reg.iter().find(|e| e.id() == id)?;
let m = shared_moment(q);
Some(leaf_output(e.as_ref(), &m, q))
}
#[must_use]
pub fn cast_all_detailed(reg: &Leaves, q: &Query) -> Vec<LeafOutput> {
let m = shared_moment(q);
map_leaves(reg, |e| leaf_output(e.as_ref(), &m, q))
}
#[must_use]
pub fn route(reg: &Leaves, kind: &QueryKind) -> Vec<&'static str> {
let want = kind.intent();
reg.iter().filter(|e| e.answers().contains(&want)).map(|e| e.id()).collect()
}
#[must_use]
pub fn intent_catalog(reg: &Leaves) -> Vec<(&'static IntentSpec, Vec<&'static str>)> {
intents()
.iter()
.map(|spec| {
let leaves = reg.iter().filter(|e| e.answers().contains(&spec.id)).map(|e| e.id()).collect();
(spec, leaves)
})
.collect()
}
fn shared_moment(q: &Query) -> Moment {
Moment::new(q.year, q.month, q.day, q.hour, q.minute, q.tz)
}
fn leaf_output(e: &dyn CastingEngine, m: &Moment, q: &Query) -> LeafOutput {
LeafOutput {
id: e.id(),
name: e.name(),
family: e.family(),
family_label: e.family().label(),
profile: e.profile(),
schools: e.schools(),
effective_school: effective_school_id(e, q),
chart: e.cast(m, q),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mingli_contract::{d, s, DetItem, Determinism, Family, SchoolItem};
#[derive(Debug, Default)]
struct Alpha;
impl CastingEngine for Alpha {
fn id(&self) -> &'static str {
"alpha"
}
fn name(&self) -> &'static str {
"假叶甲"
}
fn family(&self) -> Family {
Family::Cyclic
}
fn cast(&self, m: &Moment, q: &Query) -> Value {
serde_json::json!({ "jdn": m.civil_day, "year": q.year })
}
fn profile(&self) -> &'static [DetItem] {
const { &[d("假谱", Determinism::Det, "测试用")] }
}
fn schools(&self) -> &'static [SchoolItem] {
const { &[s("one", "甲流派", true, "默认"), s("two", "乙流派", false, "备选")] }
}
}
#[derive(Debug, Default)]
struct Beta;
impl CastingEngine for Beta {
fn id(&self) -> &'static str {
"beta"
}
fn name(&self) -> &'static str {
"假叶乙"
}
fn family(&self) -> Family {
Family::Sampling
}
fn cast(&self, _m: &Moment, _q: &Query) -> Value {
Value::Null
}
}
fn fake_registry() -> Vec<Box<dyn CastingEngine>> {
vec![Box::new(Alpha), Box::new(Beta)]
}
fn sample() -> Query {
Query {
year: 1990,
month: 6,
day: 15,
hour: 14,
minute: 30,
tz: 8.0,
gender: None,
latitude: None,
longitude: None,
seed: None,
name: None,
schools: BTreeMap::new(),
}
}
#[test]
fn cast_all_covers_the_injected_registry() {
let out = cast_all(&fake_registry(), &sample());
assert_eq!(out.len(), 2);
assert_eq!(out["alpha"]["year"], 1990);
assert_eq!(out["beta"], Value::Null);
}
#[test]
fn cast_one_selects_and_rejects_unknown() {
let reg = fake_registry();
let q = sample();
let one = cast_one(®, "alpha", &q).expect("alpha 应在注册表内");
assert_eq!(one.name, "假叶甲");
assert_eq!(one.chart, cast_all(®, &q)["alpha"]);
assert!(cast_one(®, "nope", &q).is_none());
}
#[test]
fn detailed_preserves_order_and_carries_metadata() {
let out = cast_all_detailed(&fake_registry(), &sample());
assert_eq!(out.iter().map(|l| l.id).collect::<Vec<_>>(), ["alpha", "beta"]);
assert_eq!(out[0].family_label, "循环群/CRT");
assert_eq!(out[0].profile.len(), 1);
assert_eq!(out[0].effective_school, "one");
assert_eq!(out[1].effective_school, "");
assert!(out[1].profile.is_empty() && out[1].schools.is_empty());
}
#[test]
fn explicit_school_overrides_default() {
let mut q = sample();
q.schools.insert("alpha".to_string(), "two".to_string());
let out = cast_all_detailed(&fake_registry(), &q);
assert_eq!(out[0].effective_school, "two");
}
#[test]
fn shared_moment_is_computed_once_per_call() {
let reg = fake_registry();
assert_eq!(cast_all(®, &sample()), cast_all(®, &sample()));
}
#[test]
fn route_natal_returns_whole_registry_in_order() {
let reg = fake_registry();
let ids = route(®, &QueryKind::Natal(sample()));
assert_eq!(ids, ["alpha", "beta"]);
}
#[test]
fn route_non_natal_intersects_with_registry() {
let reg = fake_registry();
let kind = QueryKind::Election {
window_start: AskTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, tz: 8.0 },
window_end: AskTime { year: 2026, month: 1, day: 8, hour: 0, minute: 0, tz: 8.0 },
category: "婚".to_string(),
};
assert!(route(®, &kind).is_empty());
}
use mingli_contract::AskTime;
}