//! Tests for the news ticker system (#591, D-036). //! //! Covers: //! - D-036: Sova Transit District — The Last Shift bar shows news ticker //! - D-010 principle 4: ticker rotation must use SimRng (deterministic) //! - #591: TickerPool loads ticker/the-last-shift.yaml, rotates every 200 ticks, //! populates current_ticker in ObserverSnapshot when player is in "bar" zone //! //! Test structure: //! - Layer 1 (pure): validate the ticker YAML content (30 headlines, required fields) //! - Layer 2 (#[ignore]): TickerPool resource loads and rotates correctly (pending #591) //! - Layer 2 (#[ignore]): current_ticker is None outside "bar" zone (pending #591) //! - Layer 2 (#[ignore]): current_ticker is Some when in "bar" zone (pending #591) //! //! The Layer 1 tests run immediately and guard against content authoring errors. //! The Layer 2 tests become runnable once TickerPool is registered in the content plugin. // --------------------------------------------------------------------------- // Layer 1: ticker YAML content validation (no ECS needed) // --------------------------------------------------------------------------- use std::path::PathBuf; fn ticker_yaml_path() -> PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); PathBuf::from(manifest_dir) .join("content/campaigns/main/systems/van-maanens-star/stations/sova/districts/transit/ticker/the-last-shift.yaml") } /// Minimal YAML structure for parsing just what we need to validate. #[derive(serde::Deserialize)] struct TickerFile { location: String, feed: String, headlines: Vec, } #[derive(serde::Deserialize)] struct HeadlineEntry { id: String, text: String, category: String, // dual_lens intentionally omitted — it's authoring metadata, not wire data (D-036) } #[test] fn ticker_yaml_exists_and_parses() { let path = ticker_yaml_path(); assert!( path.exists(), "Ticker YAML must exist at {:?} (D-036, #591). Run content generation if missing.", path ); let content = std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("Failed to read ticker YAML: {}", e)); let _: TickerFile = serde_yaml::from_str(&content) .unwrap_or_else(|e| panic!("Ticker YAML failed to parse: {}\nFile: {:?}", e, path)); } #[test] fn ticker_yaml_has_30_headlines() { // Sprint 12 #306 delivered exactly 30 headlines. The pool size affects rotation coverage. // If this fails, a headline was accidentally removed from the authored content. let path = ticker_yaml_path(); if !path.exists() { eprintln!("Skipping: ticker YAML not found"); return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); assert_eq!( file.headlines.len(), 30, "Ticker pool must have exactly 30 headlines (Sprint 12, #306). \ Found {}. Do not add or remove headlines without updating this test.", file.headlines.len() ); } #[test] fn ticker_yaml_location_is_the_last_shift() { let path = ticker_yaml_path(); if !path.exists() { return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); assert_eq!( file.location, "the-last-shift", "Ticker file must be scoped to 'the-last-shift' location (D-036)" ); assert_eq!( file.feed, "meridian", "Ticker feed must be 'meridian' (D-036 Meridian Feed)" ); } #[test] fn ticker_yaml_all_headlines_have_required_fields() { let path = ticker_yaml_path(); if !path.exists() { return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); for (i, headline) in file.headlines.iter().enumerate() { assert!(!headline.id.is_empty(), "Headline[{}] missing id field", i); assert!( !headline.text.is_empty(), "Headline[{}] (id={}) has empty text", i, headline.id ); assert!( !headline.category.is_empty(), "Headline[{}] (id={}) missing category", i, headline.id ); } } #[test] fn ticker_yaml_ids_are_unique() { let path = ticker_yaml_path(); if !path.exists() { return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); let mut seen = std::collections::HashSet::new(); for headline in &file.headlines { assert!( seen.insert(headline.id.clone()), "Duplicate ticker headline id: '{}' — each headline must have a unique id (#591)", headline.id ); } } #[test] fn ticker_yaml_categories_are_valid() { // D-036 defines 6 categories: freight, politics, infrastructure, sports, commission, community let valid_categories = [ "freight", "politics", "infrastructure", "sports", "commission", "community", ]; let path = ticker_yaml_path(); if !path.exists() { return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); for headline in &file.headlines { assert!( valid_categories.contains(&headline.category.as_str()), "Headline '{}' has unknown category '{}'. Valid categories: {:?}", headline.id, headline.category, valid_categories ); } } #[test] fn ticker_yaml_category_distribution_is_sane() { // Comment in the YAML: freight (9), politics (4), infrastructure (5), sports (3), // commission (5), community (4) = 30 total. Verify no category is completely absent. let path = ticker_yaml_path(); if !path.exists() { return; } let content = std::fs::read_to_string(&path).expect("read ticker YAML"); let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML"); let mut counts: std::collections::HashMap = std::collections::HashMap::new(); for headline in &file.headlines { *counts.entry(headline.category.clone()).or_insert(0) += 1; } for cat in [ "freight", "politics", "infrastructure", "sports", "commission", "community", ] { assert!( *counts.get(cat).unwrap_or(&0) > 0, "Category '{}' has no headlines — content is missing or miscategorized", cat ); } } // --------------------------------------------------------------------------- // Layer 2: TickerPool runtime behavior (pending #591 implementation) // --------------------------------------------------------------------------- // These tests are ignored until TickerPool is implemented and pub-exported. // When #591 lands, remove the #[ignore] attributes and verify they pass. // // Expected API to implement: // - settled_reach_server::simulation::ticker::TickerPool (Resource) // - settled_reach_server::bridge::types::TickerLine { id, text, category } // - ObserverSnapshot.current_ticker: Option // - TICKER_ROTATION_TICKS: u64 = 200 (in ticker module) #[test] #[ignore = "pending TickerPool implementation (#591)"] fn ticker_pool_loads_all_30_headlines() { // TODO: TickerPool::load() should parse the YAML and hold all 30 headlines. // Verifies content loader wiring (ticker/ subdirectory is scanned). } #[test] #[ignore = "pending TickerPool implementation (#591)"] fn ticker_rotates_at_200_tick_boundary() { // TODO: run 200 ticks, assert current_headline changes. // Must use SimRng — running with same seed must produce same sequence. } #[test] #[ignore = "pending TickerPool implementation (#591)"] fn ticker_rotation_is_deterministic_under_same_seed() { // TODO: two apps, same seed, assert same ticker at tick 200 and 400. // D-010 principle 4: deterministic simulation. } #[test] #[ignore = "pending current_ticker in ObserverSnapshot (#591)"] fn current_ticker_is_none_when_player_is_not_in_bar_zone() { // TODO: player in terminal zone → snapshot.current_ticker is None. // Edge case: don't leak bar headlines into The Terminal or corridor zones. } #[test] #[ignore = "pending current_ticker in ObserverSnapshot (#591)"] fn current_ticker_is_some_when_player_is_in_bar_zone() { // TODO: player in bar zone → snapshot.current_ticker is Some. // Spec: zone_id from zone.rs LAST_SHIFT_BAR_ZONE or equivalent. } #[test] #[ignore = "pending current_ticker in ObserverSnapshot (#591)"] fn ticker_line_dual_lens_field_not_in_wire_format() { // TODO: serialize TickerLine, assert no dual_lens key in msgpack output. // D-036 says dual_lens is authoring metadata ONLY — must not cross the wire. }