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