1use mingli_contract::{effective_school_id, intents, CastingEngine, IntentSpec, LeafOutput, Moment, Query, QueryKind};
12#[cfg(not(target_arch = "wasm32"))]
13use rayon::prelude::*;
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17pub type Leaves = [Box<dyn CastingEngine>];
19
20#[cfg(not(target_arch = "wasm32"))]
26fn map_leaves<T: Send>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T + Send + Sync) -> Vec<T> {
27 reg.par_iter().map(f).collect()
28}
29
30#[cfg(target_arch = "wasm32")]
32fn map_leaves<T>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T) -> Vec<T> {
33 reg.iter().map(f).collect()
34}
35
36#[must_use]
38pub fn cast_all(reg: &Leaves, q: &Query) -> BTreeMap<String, Value> {
39 let m = shared_moment(q);
40 map_leaves(reg, |e| (e.id().to_string(), e.cast(&m, q))).into_iter().collect()
41}
42
43#[must_use]
46pub fn cast_one(reg: &Leaves, id: &str, q: &Query) -> Option<LeafOutput> {
47 let e = reg.iter().find(|e| e.id() == id)?;
48 let m = shared_moment(q);
49 Some(leaf_output(e.as_ref(), &m, q))
50}
51
52#[must_use]
54pub fn cast_all_detailed(reg: &Leaves, q: &Query) -> Vec<LeafOutput> {
55 let m = shared_moment(q);
56 map_leaves(reg, |e| leaf_output(e.as_ref(), &m, q))
57}
58
59#[must_use]
67pub fn route(reg: &Leaves, kind: &QueryKind) -> Vec<&'static str> {
68 let want = kind.intent();
69 reg.iter().filter(|e| e.answers().contains(&want)).map(|e| e.id()).collect()
70}
71
72#[must_use]
77pub fn intent_catalog(reg: &Leaves) -> Vec<(&'static IntentSpec, Vec<&'static str>)> {
78 intents()
79 .iter()
80 .map(|spec| {
81 let leaves = reg.iter().filter(|e| e.answers().contains(&spec.id)).map(|e| e.id()).collect();
82 (spec, leaves)
83 })
84 .collect()
85}
86
87fn shared_moment(q: &Query) -> Moment {
89 Moment::new(q.year, q.month, q.day, q.hour, q.minute, q.tz)
90}
91
92fn leaf_output(e: &dyn CastingEngine, m: &Moment, q: &Query) -> LeafOutput {
93 LeafOutput {
94 id: e.id(),
95 name: e.name(),
96 family: e.family(),
97 family_label: e.family().label(),
98 profile: e.profile(),
99 schools: e.schools(),
100 effective_school: effective_school_id(e, q),
101 chart: e.cast(m, q),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use mingli_contract::{d, s, DetItem, Determinism, Family, SchoolItem};
109
110 #[derive(Debug, Default)]
112 struct Alpha;
113 impl CastingEngine for Alpha {
114 fn id(&self) -> &'static str {
115 "alpha"
116 }
117 fn name(&self) -> &'static str {
118 "假叶甲"
119 }
120 fn family(&self) -> Family {
121 Family::Cyclic
122 }
123 fn cast(&self, m: &Moment, q: &Query) -> Value {
124 serde_json::json!({ "jdn": m.civil_day, "year": q.year })
125 }
126 fn profile(&self) -> &'static [DetItem] {
127 const { &[d("假谱", Determinism::Det, "测试用")] }
128 }
129 fn schools(&self) -> &'static [SchoolItem] {
130 const { &[s("one", "甲流派", true, "默认"), s("two", "乙流派", false, "备选")] }
131 }
132 }
133
134 #[derive(Debug, Default)]
136 struct Beta;
137 impl CastingEngine for Beta {
138 fn id(&self) -> &'static str {
139 "beta"
140 }
141 fn name(&self) -> &'static str {
142 "假叶乙"
143 }
144 fn family(&self) -> Family {
145 Family::Sampling
146 }
147 fn cast(&self, _m: &Moment, _q: &Query) -> Value {
148 Value::Null
149 }
150 }
151
152 fn fake_registry() -> Vec<Box<dyn CastingEngine>> {
153 vec![Box::new(Alpha), Box::new(Beta)]
154 }
155
156 fn sample() -> Query {
157 Query {
158 year: 1990,
159 month: 6,
160 day: 15,
161 hour: 14,
162 minute: 30,
163 tz: 8.0,
164 gender: None,
165 latitude: None,
166 longitude: None,
167 seed: None,
168 name: None,
169 schools: BTreeMap::new(),
170 }
171 }
172
173 #[test]
174 fn cast_all_covers_the_injected_registry() {
175 let out = cast_all(&fake_registry(), &sample());
176 assert_eq!(out.len(), 2);
177 assert_eq!(out["alpha"]["year"], 1990);
178 assert_eq!(out["beta"], Value::Null);
179 }
180
181 #[test]
182 fn cast_one_selects_and_rejects_unknown() {
183 let reg = fake_registry();
184 let q = sample();
185 let one = cast_one(®, "alpha", &q).expect("alpha 应在注册表内");
186 assert_eq!(one.name, "假叶甲");
187 assert_eq!(one.chart, cast_all(®, &q)["alpha"]);
188 assert!(cast_one(®, "nope", &q).is_none());
189 }
190
191 #[test]
192 fn detailed_preserves_order_and_carries_metadata() {
193 let out = cast_all_detailed(&fake_registry(), &sample());
194 assert_eq!(out.iter().map(|l| l.id).collect::<Vec<_>>(), ["alpha", "beta"]);
195 assert_eq!(out[0].family_label, "循环群/CRT");
196 assert_eq!(out[0].profile.len(), 1);
197 assert_eq!(out[0].effective_school, "one");
199 assert_eq!(out[1].effective_school, "");
200 assert!(out[1].profile.is_empty() && out[1].schools.is_empty());
201 }
202
203 #[test]
204 fn explicit_school_overrides_default() {
205 let mut q = sample();
206 q.schools.insert("alpha".to_string(), "two".to_string());
207 let out = cast_all_detailed(&fake_registry(), &q);
208 assert_eq!(out[0].effective_school, "two");
209 }
210
211 #[test]
212 fn shared_moment_is_computed_once_per_call() {
213 let reg = fake_registry();
215 assert_eq!(cast_all(®, &sample()), cast_all(®, &sample()));
216 }
217
218 #[test]
219 fn route_natal_returns_whole_registry_in_order() {
220 let reg = fake_registry();
221 let ids = route(®, &QueryKind::Natal(sample()));
222 assert_eq!(ids, ["alpha", "beta"]);
223 }
224
225 #[test]
226 fn route_non_natal_intersects_with_registry() {
227 let reg = fake_registry();
230 let kind = QueryKind::Election {
231 window_start: AskTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, tz: 8.0 },
232 window_end: AskTime { year: 2026, month: 1, day: 8, hour: 0, minute: 0, tz: 8.0 },
233 category: "婚".to_string(),
234 };
235 assert!(route(®, &kind).is_empty());
236 }
237
238 use mingli_contract::AskTime;
239}