use crate::core::labels::zh_cn;
use crate::core::model::bureau::FiveElementBureau;
use crate::core::model::calendar::Gender;
use crate::core::model::chart::{
Chart, DecorativeStarFamily, DecorativeStarPlacement, HoroscopeChart, MutagenActivation,
Palace, PalaceGridPosition, PalaceName, StarPlacement, TemporalLayer, VISUAL_BRANCH_ORDER,
palace_grid_position,
};
use crate::core::model::star::mutagen::Scope;
use crate::core::model::star::{Brightness, StarCategory, StarKind, StarName, mutagen::Mutagen};
use lunar_lite::{EarthlyBranch, HeavenlyStem};
use serde::{Deserialize, Serialize};
const SELECTOR_ORDER: [Scope; 7] = [
Scope::Natal,
Scope::Decadal,
Scope::Age,
Scope::Yearly,
Scope::Monthly,
Scope::Daily,
Scope::Hourly,
];
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartViewSnapshot {
pub center: StaticChartCenterView,
pub palaces: Vec<StaticPalaceView>,
pub selectors: Vec<StaticChartSelectorView>,
pub active_scopes: Vec<Scope>,
pub highlights: Vec<HighlightView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartCenterView {
pub gender: Gender,
pub birth_year_stem: HeavenlyStem,
pub birth_year_stem_zh: String,
pub birth_year_branch: EarthlyBranch,
pub birth_year_branch_zh: String,
pub five_element_bureau: Option<FiveElementBureau>,
pub life_palace_branch: Option<EarthlyBranch>,
pub life_palace_branch_zh: Option<String>,
pub body_palace_branch: Option<EarthlyBranch>,
pub body_palace_branch_zh: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticPalaceView {
pub branch: EarthlyBranch,
pub branch_zh: String,
pub grid_position: PalaceGridPosition,
pub name: PalaceName,
pub name_zh: String,
pub stem: HeavenlyStem,
pub stem_zh: String,
pub roles: Vec<StaticPalaceRole>,
pub major_stars: Vec<StaticTypedStarView>,
pub minor_stars: Vec<StaticTypedStarView>,
pub adjective_stars: Vec<StaticTypedStarView>,
pub other_typed_stars: Vec<StaticTypedStarView>,
pub decorative_stars: Vec<StaticDecorativeStarView>,
pub overlays: Vec<StaticTemporalOverlayView>,
pub highlights: Vec<HighlightView>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "palace_name", rename_all = "snake_case")]
pub enum StaticPalaceRole {
NatalPalace(PalaceName),
BodyPalace,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticTypedStarView {
pub name: StarName,
pub name_zh: String,
pub category: StarCategory,
pub category_zh: String,
pub kind: StarKind,
pub kind_zh: String,
pub brightness: Brightness,
pub brightness_zh: String,
pub mutagen: Option<Mutagen>,
pub mutagen_zh: Option<String>,
}
impl StaticTypedStarView {
fn from_star_placement(placement: &StarPlacement) -> Self {
Self {
name: placement.name(),
name_zh: zh_cn::star_name_zh(placement.name()).to_owned(),
category: placement.category(),
category_zh: zh_cn::star_category_zh(placement.category()).to_owned(),
kind: placement.kind(),
kind_zh: zh_cn::star_kind_zh(placement.kind()).to_owned(),
brightness: placement.brightness(),
brightness_zh: zh_cn::brightness_zh(placement.brightness()).to_owned(),
mutagen: placement.mutagen(),
mutagen_zh: placement
.mutagen()
.map(|mutagen| zh_cn::mutagen_zh(mutagen).to_owned()),
}
}
fn order_key(&self) -> (StarKind, StarName, Brightness, Option<Mutagen>) {
(self.kind, self.name, self.brightness, self.mutagen)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticDecorativeStarView {
pub name: StarName,
pub name_zh: String,
pub family: DecorativeStarFamily,
pub family_zh: String,
}
impl StaticDecorativeStarView {
fn from_decorative_star_placement(placement: &DecorativeStarPlacement) -> Self {
Self {
name: placement.name(),
name_zh: zh_cn::star_name_zh(placement.name()).to_owned(),
family: placement.family(),
family_zh: zh_cn::decorative_star_family_zh(placement.family()).to_owned(),
}
}
fn order_key(&self) -> (DecorativeStarFamily, StarName) {
(self.family, self.name)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartSelectorView {
pub scope: Scope,
pub label_zh: String,
pub enabled: bool,
pub selected: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticTemporalOverlayView {
pub scope: Scope,
pub temporal_palace_name: Option<PalaceName>,
pub temporal_palace_name_zh: Option<String>,
pub typed_stars: Vec<StaticTypedStarView>,
pub decorative_stars: Vec<StaticDecorativeStarView>,
pub mutagens: Vec<StaticOverlayMutagenView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticOverlayMutagenView {
pub star: StarName,
pub star_zh: String,
pub mutagen: Mutagen,
pub mutagen_zh: String,
}
impl StaticOverlayMutagenView {
fn from_activation(activation: &MutagenActivation) -> Self {
Self {
star: activation.target_star(),
star_zh: zh_cn::star_name_zh(activation.target_star()).to_owned(),
mutagen: activation.mutagen(),
mutagen_zh: zh_cn::mutagen_zh(activation.mutagen()).to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HighlightView {
pub id: String,
pub label_zh: Option<String>,
pub scope: Option<Scope>,
pub involved_palaces: Vec<EarthlyBranch>,
pub involved_stars: Vec<StarName>,
pub involved_mutagens: Vec<Mutagen>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StaticChartViewRequest {
pub visible_scopes: Vec<Scope>,
}
impl StaticChartViewSnapshot {
pub fn from_chart(chart: &Chart) -> Self {
let palaces = build_palaces(chart, &[]);
let present = [Scope::Natal];
let selected = [Scope::Natal];
Self {
center: StaticChartCenterView::from_chart(chart),
palaces,
selectors: build_selectors(&present, &selected),
active_scopes: active_scopes(&selected),
highlights: Vec::new(),
}
}
pub fn from_horoscope_chart(chart: &HoroscopeChart) -> Self {
let mut visible_scopes = vec![Scope::Natal];
visible_scopes.extend(
present_scopes(chart)
.into_iter()
.filter(|s| *s != Scope::Natal),
);
Self::from_horoscope_chart_with(chart, &StaticChartViewRequest { visible_scopes })
}
pub fn from_horoscope_chart_with(
chart: &HoroscopeChart,
request: &StaticChartViewRequest,
) -> Self {
let natal = chart.natal();
let present = present_scopes(chart);
let selected: Vec<Scope> = SELECTOR_ORDER
.into_iter()
.filter(|scope| {
*scope == Scope::Natal
|| (present.contains(scope) && request.visible_scopes.contains(scope))
})
.collect();
let overlay_layers: Vec<&TemporalLayer> = SELECTOR_ORDER
.into_iter()
.filter(|scope| *scope != Scope::Natal && selected.contains(scope))
.flat_map(|scope| {
chart
.layers()
.iter()
.filter(move |layer| layer.scope() == scope)
})
.collect();
Self {
center: StaticChartCenterView::from_chart(natal),
palaces: build_palaces(natal, &overlay_layers),
selectors: build_selectors(&present, &selected),
active_scopes: active_scopes(&selected),
highlights: Vec::new(),
}
}
}
impl StaticChartCenterView {
fn from_chart(chart: &Chart) -> Self {
let life_palace_branch = chart.life_palace().map(Palace::branch);
let body_palace_branch = chart.body_palace_branch();
Self {
gender: chart.birth_context().gender(),
birth_year_stem: chart.birth_year().stem(),
birth_year_stem_zh: zh_cn::heavenly_stem_zh(chart.birth_year().stem()).to_owned(),
birth_year_branch: chart.birth_year().branch(),
birth_year_branch_zh: zh_cn::earthly_branch_zh(chart.birth_year().branch()).to_owned(),
five_element_bureau: chart.five_element_bureau(),
life_palace_branch,
life_palace_branch_zh: life_palace_branch
.map(|branch| zh_cn::earthly_branch_zh(branch).to_owned()),
body_palace_branch,
body_palace_branch_zh: body_palace_branch
.map(|branch| zh_cn::earthly_branch_zh(branch).to_owned()),
}
}
}
fn build_palaces(chart: &Chart, overlay_layers: &[&TemporalLayer]) -> Vec<StaticPalaceView> {
VISUAL_BRANCH_ORDER
.into_iter()
.filter_map(|branch| {
chart
.palaces()
.iter()
.find(|palace| palace.branch() == branch)
.map(|palace| StaticPalaceView::from_palace(chart, palace, overlay_layers))
})
.collect()
}
impl StaticPalaceView {
fn from_palace(chart: &Chart, palace: &Palace, overlay_layers: &[&TemporalLayer]) -> Self {
let mut roles = vec![StaticPalaceRole::NatalPalace(palace.name())];
if chart.is_body_palace_branch(palace.branch()) {
roles.push(StaticPalaceRole::BodyPalace);
}
let mut major_stars = Vec::new();
let mut minor_stars = Vec::new();
let mut adjective_stars = Vec::new();
let other_typed_stars = Vec::new();
for placement in palace.stars() {
let star = StaticTypedStarView::from_star_placement(placement);
match star.category {
StarCategory::Major => major_stars.push(star),
StarCategory::Minor => minor_stars.push(star),
StarCategory::Adjective => adjective_stars.push(star),
}
}
major_stars.sort_by_key(StaticTypedStarView::order_key);
minor_stars.sort_by_key(StaticTypedStarView::order_key);
adjective_stars.sort_by_key(StaticTypedStarView::order_key);
let mut decorative_stars: Vec<StaticDecorativeStarView> = palace
.decorative_stars()
.iter()
.map(StaticDecorativeStarView::from_decorative_star_placement)
.collect();
decorative_stars.sort_by_key(StaticDecorativeStarView::order_key);
let overlays = overlay_layers
.iter()
.map(|layer| StaticTemporalOverlayView::from_layer(layer, palace.branch()))
.collect();
Self {
branch: palace.branch(),
branch_zh: zh_cn::earthly_branch_zh(palace.branch()).to_owned(),
grid_position: palace_grid_position(palace.branch()),
name: palace.name(),
name_zh: zh_cn::palace_name_zh(palace.name()).to_owned(),
stem: palace.stem(),
stem_zh: zh_cn::heavenly_stem_zh(palace.stem()).to_owned(),
roles,
major_stars,
minor_stars,
adjective_stars,
other_typed_stars,
decorative_stars,
overlays,
highlights: Vec::new(),
}
}
}
impl StaticTemporalOverlayView {
fn from_layer(layer: &TemporalLayer, branch: EarthlyBranch) -> Self {
let temporal_palace_name = layer
.palace_layout()
.and_then(|layout| layout.name_for_branch(branch));
let mut typed_stars: Vec<StaticTypedStarView> = layer
.placements()
.iter()
.filter(|placement| placement.branch() == branch)
.map(|placement| StaticTypedStarView::from_star_placement(placement.placement()))
.collect();
typed_stars.sort_by_key(StaticTypedStarView::order_key);
let mut decorative_stars: Vec<StaticDecorativeStarView> = layer
.temporal_decorative_stars()
.iter()
.filter(|placement| placement.branch() == branch)
.map(|placement| {
StaticDecorativeStarView::from_decorative_star_placement(placement.placement())
})
.collect();
decorative_stars.sort_by_key(StaticDecorativeStarView::order_key);
let mutagens = layer
.activations()
.iter()
.filter(|activation| activation.target_branch() == branch)
.map(StaticOverlayMutagenView::from_activation)
.collect();
Self {
scope: layer.scope(),
temporal_palace_name,
temporal_palace_name_zh: temporal_palace_name
.map(|name| zh_cn::palace_name_zh(name).to_owned()),
typed_stars,
decorative_stars,
mutagens,
}
}
}
fn present_scopes(chart: &HoroscopeChart) -> Vec<Scope> {
SELECTOR_ORDER
.into_iter()
.filter(|scope| {
*scope == Scope::Natal || chart.layers().iter().any(|layer| layer.scope() == *scope)
})
.collect()
}
fn build_selectors(present: &[Scope], selected: &[Scope]) -> Vec<StaticChartSelectorView> {
SELECTOR_ORDER
.into_iter()
.map(|scope| StaticChartSelectorView {
scope,
label_zh: zh_cn::scope_zh(scope).to_owned(),
enabled: present.contains(&scope),
selected: selected.contains(&scope),
})
.collect()
}
fn active_scopes(selected: &[Scope]) -> Vec<Scope> {
SELECTOR_ORDER
.into_iter()
.filter(|scope| selected.contains(scope))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::model::chart::PALACE_COUNT;
use crate::core::{
ChartAlgorithmKind, LunarChartRequest, LunarDay, LunarMonth, MethodProfile, StemBranch,
by_lunar,
};
use std::collections::HashSet;
fn sample_chart() -> Chart {
let birth_year = StemBranch::from_lunar_year(1990);
let method_profile = MethodProfile::new(
"1990_05_17_chen_female",
ChartAlgorithmKind::QuanShu,
"static chart view unit test",
);
let request = LunarChartRequest::builder()
.lunar_year(1990)
.lunar_month(LunarMonth::new(5).expect("valid lunar month"))
.lunar_day(LunarDay::new(17).expect("valid lunar day"))
.iztro_time_index(4)
.expect("valid time index")
.gender(Gender::Female)
.birth_year_stem(birth_year.stem())
.birth_year_branch(birth_year.branch())
.is_leap_month(false)
.fix_leap(true)
.method_profile(method_profile)
.build()
.expect("lunar chart request should build");
by_lunar(request).expect("by_lunar should build the canonical chart")
}
#[test]
fn snapshot_has_exactly_twelve_palaces() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert_eq!(snapshot.palaces.len(), PALACE_COUNT);
}
#[test]
fn every_branch_appears_exactly_once() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
let branches: HashSet<EarthlyBranch> = snapshot
.palaces
.iter()
.map(|palace| palace.branch)
.collect();
assert_eq!(branches.len(), PALACE_COUNT);
assert_eq!(branches, VISUAL_BRANCH_ORDER.into_iter().collect());
}
#[test]
fn every_grid_position_appears_exactly_once_and_matches_layout() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
let positions: HashSet<(u8, u8)> = snapshot
.palaces
.iter()
.map(|palace| (palace.grid_position.row(), palace.grid_position.column()))
.collect();
assert_eq!(positions.len(), PALACE_COUNT);
for palace in &snapshot.palaces {
assert_eq!(palace.grid_position, palace_grid_position(palace.branch));
}
}
#[test]
fn every_palace_has_chinese_labels() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
assert!(!palace.branch_zh.is_empty());
assert!(!palace.name_zh.is_empty());
assert!(!palace.stem_zh.is_empty());
}
}
#[test]
fn every_typed_star_has_chinese_labels_and_mutagen_labels_match() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
let typed = palace
.major_stars
.iter()
.chain(&palace.minor_stars)
.chain(&palace.adjective_stars)
.chain(&palace.other_typed_stars);
for star in typed {
assert!(!star.name_zh.is_empty());
assert!(!star.kind_zh.is_empty());
assert!(!star.category_zh.is_empty());
assert_eq!(star.mutagen.is_some(), star.mutagen_zh.is_some());
}
}
}
#[test]
fn every_decorative_star_has_chinese_labels() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
for palace in &snapshot.palaces {
for star in &palace.decorative_stars {
assert!(!star.name_zh.is_empty());
assert!(!star.family_zh.is_empty());
}
}
}
#[test]
fn typed_stars_are_grouped_by_category_without_loss() {
let chart = sample_chart();
let snapshot = StaticChartViewSnapshot::from_chart(&chart);
for palace in &snapshot.palaces {
for star in &palace.major_stars {
assert_eq!(star.category, StarCategory::Major);
}
for star in &palace.minor_stars {
assert_eq!(star.category, StarCategory::Minor);
}
for star in &palace.adjective_stars {
assert_eq!(star.category, StarCategory::Adjective);
}
let source = chart
.palaces()
.iter()
.find(|p| p.branch() == palace.branch)
.expect("palace by branch");
let grouped = palace.major_stars.len()
+ palace.minor_stars.len()
+ palace.adjective_stars.len()
+ palace.other_typed_stars.len();
assert_eq!(grouped, source.stars().len());
}
assert!(
snapshot
.palaces
.iter()
.all(|p| p.other_typed_stars.is_empty())
);
}
#[test]
fn highlights_are_empty() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert!(snapshot.highlights.is_empty());
assert!(snapshot.palaces.iter().all(|p| p.highlights.is_empty()));
}
#[test]
fn repeated_construction_serializes_identically() {
let chart = sample_chart();
let first = serde_json::to_string(&StaticChartViewSnapshot::from_chart(&chart))
.expect("snapshot should serialize");
let second = serde_json::to_string(&StaticChartViewSnapshot::from_chart(&chart))
.expect("snapshot should serialize");
assert_eq!(first, second);
}
#[test]
fn constructing_the_view_does_not_mutate_natal_facts() {
let chart = sample_chart();
let before = chart.clone();
let _ = StaticChartViewSnapshot::from_chart(&chart);
assert_eq!(chart, before);
}
#[test]
fn from_chart_enables_and_selects_only_natal() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert_eq!(snapshot.active_scopes, vec![Scope::Natal]);
for selector in &snapshot.selectors {
let is_natal = selector.scope == Scope::Natal;
assert_eq!(selector.enabled, is_natal);
assert_eq!(selector.selected, is_natal);
}
let order: Vec<Scope> = snapshot.selectors.iter().map(|s| s.scope).collect();
assert_eq!(order, SELECTOR_ORDER.to_vec());
let natal = &snapshot.selectors[0];
assert_eq!(natal.label_zh, "本命");
}
#[test]
fn from_chart_produces_no_overlays() {
let snapshot = StaticChartViewSnapshot::from_chart(&sample_chart());
assert!(snapshot.palaces.iter().all(|p| p.overlays.is_empty()));
}
use crate::core::model::chart::{ScopedStarPlacement, TemporalContext, TemporalLayer};
const OVERLAY_BRANCH: EarthlyBranch = EarthlyBranch::Zi;
fn sample_horoscope_chart() -> HoroscopeChart {
let mut chart = HoroscopeChart::new(sample_chart());
let period = StemBranch::from_lunar_year(2020);
let decadal_star = StarPlacement::new(
StarName::YunLu,
StarKind::LuCun,
Brightness::Unknown,
None,
Scope::Decadal,
);
let decadal_layer = TemporalLayer::try_new(
Scope::Decadal,
TemporalContext::Decadal {
stem_branch: period,
start_age: 6,
},
vec![ScopedStarPlacement::new(OVERLAY_BRANCH, decadal_star)],
vec![MutagenActivation::new(
Scope::Decadal,
StarName::ZiWei,
OVERLAY_BRANCH,
Mutagen::Lu,
)],
)
.expect("decadal layer should build");
chart.push_layer(decadal_layer);
let yearly_layer = TemporalLayer::try_new(
Scope::Yearly,
TemporalContext::Yearly {
stem_branch: period,
lunar_year: 2020,
},
Vec::new(),
Vec::new(),
)
.expect("yearly layer should build");
chart.push_layer(yearly_layer);
chart
}
#[test]
fn from_horoscope_chart_enables_and_selects_present_scopes() {
let chart = sample_horoscope_chart();
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&chart);
let present = [Scope::Natal, Scope::Decadal, Scope::Yearly];
for selector in &snapshot.selectors {
let is_present = present.contains(&selector.scope);
assert_eq!(selector.enabled, is_present, "{:?}", selector.scope);
assert_eq!(selector.selected, is_present, "{:?}", selector.scope);
}
assert_eq!(
snapshot.active_scopes,
vec![Scope::Natal, Scope::Decadal, Scope::Yearly]
);
}
#[test]
fn selector_labels_are_correct() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
let labels: Vec<(Scope, &str)> = snapshot
.selectors
.iter()
.map(|s| (s.scope, s.label_zh.as_str()))
.collect();
assert_eq!(
labels,
vec![
(Scope::Natal, "本命"),
(Scope::Decadal, "大限"),
(Scope::Age, "小限"),
(Scope::Yearly, "流年"),
(Scope::Monthly, "流月"),
(Scope::Daily, "流日"),
(Scope::Hourly, "流时"),
]
);
}
#[test]
fn absent_scopes_are_disabled_deterministically() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
for scope in [Scope::Age, Scope::Monthly, Scope::Daily, Scope::Hourly] {
let selector = snapshot
.selectors
.iter()
.find(|s| s.scope == scope)
.expect("selector present");
assert!(!selector.enabled);
assert!(!selector.selected);
}
}
#[test]
fn active_scopes_match_requested_visible_scopes() {
let chart = sample_horoscope_chart();
let request = StaticChartViewRequest {
visible_scopes: vec![Scope::Natal, Scope::Decadal, Scope::Monthly],
};
let snapshot = StaticChartViewSnapshot::from_horoscope_chart_with(&chart, &request);
assert_eq!(snapshot.active_scopes, vec![Scope::Natal, Scope::Decadal]);
let yearly = snapshot
.selectors
.iter()
.find(|s| s.scope == Scope::Yearly)
.unwrap();
assert!(yearly.enabled);
assert!(!yearly.selected);
}
#[test]
fn natal_is_always_active_for_horoscope_static_views() {
let chart = sample_horoscope_chart();
let request = StaticChartViewRequest {
visible_scopes: vec![Scope::Yearly],
};
let snapshot = StaticChartViewSnapshot::from_horoscope_chart_with(&chart, &request);
assert_eq!(snapshot.active_scopes, vec![Scope::Natal, Scope::Yearly]);
let natal = snapshot
.selectors
.iter()
.find(|selector| selector.scope == Scope::Natal)
.expect("natal selector");
assert!(natal.enabled);
assert!(natal.selected);
}
#[test]
fn selected_overlays_appear_only_on_the_overlay_branch() {
let snapshot = StaticChartViewSnapshot::from_horoscope_chart(&sample_horoscope_chart());
for palace in &snapshot.palaces {
assert_eq!(palace.overlays.len(), 2, "{:?}", palace.branch);
let decadal = palace
.overlays
.iter()
.find(|o| o.scope == Scope::Decadal)
.expect("decadal overlay");
if palace.branch == OVERLAY_BRANCH {
assert_eq!(decadal.typed_stars.len(), 1);
assert_eq!(decadal.typed_stars[0].name, StarName::YunLu);
assert_eq!(decadal.mutagens.len(), 1);
assert_eq!(decadal.mutagens[0].mutagen, Mutagen::Lu);
} else {
assert!(decadal.typed_stars.is_empty());
assert!(decadal.mutagens.is_empty());
}
}
}
#[test]
fn selecting_scopes_changes_only_overlays_not_natal_facts() {
let chart = sample_horoscope_chart();
let natal_only = StaticChartViewSnapshot::from_chart(chart.natal());
let with_overlays = StaticChartViewSnapshot::from_horoscope_chart(&chart);
assert_eq!(natal_only.center, with_overlays.center);
for (a, b) in natal_only.palaces.iter().zip(&with_overlays.palaces) {
assert_eq!(a.branch, b.branch);
assert_eq!(a.major_stars, b.major_stars);
assert_eq!(a.minor_stars, b.minor_stars);
assert_eq!(a.adjective_stars, b.adjective_stars);
assert_eq!(a.decorative_stars, b.decorative_stars);
assert_eq!(a.roles, b.roles);
assert!(a.overlays.is_empty());
assert!(!b.overlays.is_empty());
}
}
#[test]
fn horoscope_snapshot_serializes_deterministically_and_keeps_natal_immutable() {
let chart = sample_horoscope_chart();
let before = chart.clone();
let first = serde_json::to_string(&StaticChartViewSnapshot::from_horoscope_chart(&chart))
.expect("serialize");
let second = serde_json::to_string(&StaticChartViewSnapshot::from_horoscope_chart(&chart))
.expect("serialize");
assert_eq!(first, second);
assert_eq!(chart, before);
}
}