Files
settled-reach/server/tests/news_ticker.rs
T
jpmschweitzerandClaude Opus 4.6 e8263e209e refactor(data): rename Krenn to Van Maanen's Star
System S-057 assigned to real star GJ 35 (Van Maanen's Star, DG white
dwarf at 13.9 ly). Renamed across all content, server code, docs,
decisions, wiki lore, and config files. 224 files updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:23:26 +01:00

243 lines
8.8 KiB
Rust

//! 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<HeadlineEntry>,
}
#[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<String, usize> = 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<TickerLine>
// - 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.
}