Files
settled-reach/server/tests/news_ticker.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

267 lines
9.0 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.
}