Files
settled-reach/server/tests/news_ticker.rs
T
jpmschweitzerandClaude Opus 4.7 9a10c6ffd6 chore(deps): rand 0.9.4 + compatible sweep + serde_yaml→serde_norway (#966)
Focused Rust dependency-maintenance pass from the 2026-05-23 security/freshness
review. No CVEs; one advisory cleared and one deprecated crate replaced.

- rand 0.9.2 → 0.9.4 (lockfile): clears RUSTSEC-2026-0097 (unsound with a
  custom logger using rand::rng()). Semver-compatible; rand 0.10 is a separate
  major.
- Compatible-update sweep: ~90 lockfile-only patch/minor bumps (bevy 0.18.0→
  0.18.1, clap 4.5→4.6, rayon 1.11→1.12, pathfinding 4.14→4.15, uuid 1.20→1.23,
  zerocopy, serde_json, tracing-subscriber, etc.). cargo test green.
- serde_yaml 0.9 (deprecated/archived upstream) → serde_norway 0.9, an actively
  maintained drop-in fork. In the server it is test-only (poi.rs round-trip,
  trait_modifiers.rs fixture, tests/news_ticker.rs) so it moves to
  dev-dependencies; line-previewer parses dialogue/monologue pool YAML at
  runtime, so it keeps it as a normal dependency. API is identical (from_str/
  to_string).

news_ticker.rs also picks up its share of the #967 clippy sweep (HashSet/HashMap
→ BTree, doc-list indent) since it is the same file as the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:02:57 +02: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_norway::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_norway::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_norway::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_norway::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_norway::from_str(&content).expect("parse ticker YAML");
let mut seen = std::collections::BTreeSet::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_norway::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_norway::from_str(&content).expect("parse ticker YAML");
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::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.
}