fix: review findings
- screen transitions snapshot the previous frame (ratatui swaps buffers after draw, so the old snapshot was always blank); frame loop extracted into a testable Runner - .city loading rejects structurally inconsistent files: Grid deserialize checks size vs data, load_city checks header vs body and runs City::validate (now also checks Net link endpoints) - saving from the map hands the live map screen back instead of a fresh explore screen - signage glyphs covered by the unicode-width test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ use anyhow::Result;
|
|||||||
use citygen::{City, GenParams};
|
use citygen::{City, GenParams};
|
||||||
use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
|
use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::Backend;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::{Alignment, Rect};
|
use ratatui::layout::{Alignment, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
@@ -90,11 +93,78 @@ pub fn draw_too_small(f: &mut Frame) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fixed-rate loop: poll input, tick, update, draw.
|
/// One screen plus the state the loop threads through frames.
|
||||||
pub fn run(mut screen: Box<dyn Screen>, mut st: AppState) -> Result<()> {
|
pub struct Runner {
|
||||||
|
screen: Box<dyn Screen>,
|
||||||
|
pub st: AppState,
|
||||||
|
transition: Option<Transition>,
|
||||||
|
/// Our own copy of the last drawn frame; ratatui swaps buffers after every draw.
|
||||||
|
last_frame: Option<Buffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runner {
|
||||||
|
pub fn new(screen: Box<dyn Screen>, st: AppState) -> Self {
|
||||||
|
Runner {
|
||||||
|
screen,
|
||||||
|
st,
|
||||||
|
transition: None,
|
||||||
|
last_frame: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn transition(&self) -> Option<&Transition> {
|
||||||
|
self.transition.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one frame: tick, update with `keys`, draw. Returns false when the app quits.
|
||||||
|
pub fn frame<B: Backend>(&mut self, term: &mut Terminal<B>, keys: &[KeyEvent]) -> Result<bool> {
|
||||||
|
self.st.tick += 1;
|
||||||
|
if self
|
||||||
|
.transition
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|t| t.active(self.st.tick))
|
||||||
|
&& !keys.is_empty()
|
||||||
|
{
|
||||||
|
self.transition = None;
|
||||||
|
}
|
||||||
|
match self.screen.update(&mut self.st, keys) {
|
||||||
|
ScreenCmd::Stay => {}
|
||||||
|
ScreenCmd::Switch { to, fx } => {
|
||||||
|
let snapshot = self
|
||||||
|
.last_frame
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Buffer::empty(term.get_frame().area()));
|
||||||
|
self.screen = to;
|
||||||
|
self.transition = Some(Transition::new(fx, snapshot, self.st.tick));
|
||||||
|
}
|
||||||
|
ScreenCmd::Quit => return Ok(false),
|
||||||
|
}
|
||||||
|
let st = &self.st;
|
||||||
|
let screen = &self.screen;
|
||||||
|
let transition = &self.transition;
|
||||||
|
let mut captured: Option<Buffer> = None;
|
||||||
|
term.draw(|f| {
|
||||||
|
if f.area().width < MIN_W || f.area().height < MIN_H {
|
||||||
|
draw_too_small(f);
|
||||||
|
} else {
|
||||||
|
screen.draw(st, f);
|
||||||
|
if let Some(t) = transition {
|
||||||
|
t.apply(f, st.tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captured = Some(f.buffer_mut().clone());
|
||||||
|
})?;
|
||||||
|
self.last_frame = captured;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed-rate loop: poll input, run a frame, sleep the remainder.
|
||||||
|
pub fn run(screen: Box<dyn Screen>, st: AppState) -> Result<()> {
|
||||||
install_panic_hook();
|
install_panic_hook();
|
||||||
let (_guard, mut term) = TerminalGuard::enter()?;
|
let (_guard, mut term) = TerminalGuard::enter()?;
|
||||||
let mut transition: Option<Transition> = None;
|
let mut runner = Runner::new(screen, st);
|
||||||
loop {
|
loop {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
@@ -105,29 +175,9 @@ pub fn run(mut screen: Box<dyn Screen>, mut st: AppState) -> Result<()> {
|
|||||||
keys.push(k);
|
keys.push(k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
st.tick += 1;
|
if !runner.frame(&mut term, &keys)? {
|
||||||
if transition.as_ref().is_some_and(|t| t.active(st.tick)) && !keys.is_empty() {
|
break;
|
||||||
transition = None;
|
|
||||||
}
|
}
|
||||||
match screen.update(&mut st, &keys) {
|
|
||||||
ScreenCmd::Stay => {}
|
|
||||||
ScreenCmd::Switch { to, fx } => {
|
|
||||||
let snapshot = term.current_buffer_mut().clone();
|
|
||||||
screen = to;
|
|
||||||
transition = Some(Transition::new(fx, snapshot, st.tick));
|
|
||||||
}
|
|
||||||
ScreenCmd::Quit => break,
|
|
||||||
}
|
|
||||||
term.draw(|f| {
|
|
||||||
if f.area().width < MIN_W || f.area().height < MIN_H {
|
|
||||||
draw_too_small(f);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
screen.draw(&st, f);
|
|
||||||
if let Some(t) = &transition {
|
|
||||||
t.apply(f, st.tick);
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
if let Some(rest) = FRAME.checked_sub(start.elapsed()) {
|
if let Some(rest) = FRAME.checked_sub(start.elapsed()) {
|
||||||
std::thread::sleep(rest);
|
std::thread::sleep(rest);
|
||||||
}
|
}
|
||||||
@@ -164,6 +214,42 @@ mod tests {
|
|||||||
assert!(text(&t).contains("100"));
|
assert!(text(&t).contains("100"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Says(&'static str, bool);
|
||||||
|
|
||||||
|
impl Screen for Says {
|
||||||
|
fn update(&mut self, _st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd {
|
||||||
|
if self.1 && !keys.is_empty() {
|
||||||
|
return ScreenCmd::Switch {
|
||||||
|
to: Box::new(Says("WORLD", false)),
|
||||||
|
fx: TransitionKind::SlideUp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ScreenCmd::Stay
|
||||||
|
}
|
||||||
|
fn draw(&self, _st: &AppState, f: &mut Frame) {
|
||||||
|
f.render_widget(ratatui::widgets::Paragraph::new(self.0), f.area());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_snapshots_the_previous_frame() {
|
||||||
|
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
||||||
|
let mut runner = Runner::new(Box::new(Says("HELLO", true)), AppState::new());
|
||||||
|
assert!(runner.frame(&mut t, &[]).unwrap());
|
||||||
|
assert!(text(&t).contains("HELLO"));
|
||||||
|
let key = KeyEvent::new(
|
||||||
|
crossterm::event::KeyCode::Enter,
|
||||||
|
crossterm::event::KeyModifiers::NONE,
|
||||||
|
);
|
||||||
|
assert!(runner.frame(&mut t, &[key]).unwrap());
|
||||||
|
let tr = runner.transition().expect("a transition is running");
|
||||||
|
let snapshot: String = (0..5)
|
||||||
|
.map(|x| tr.from[(x, 0)].symbol().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(snapshot, "HELLO");
|
||||||
|
assert!(text(&t).contains("WORLD") || text(&t).contains("HELLO"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn app_state_status_expires() {
|
fn app_state_status_expires() {
|
||||||
let mut st = AppState::new();
|
let mut st = AppState::new();
|
||||||
|
|||||||
@@ -81,9 +81,36 @@ fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, &'a [u8])> {
|
|||||||
|
|
||||||
pub fn load_city(path: &Path) -> Result<City> {
|
pub fn load_city(path: &Path) -> Result<City> {
|
||||||
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
|
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
let (_, body) = split(&bytes, path)?;
|
let (header, body) = split(&bytes, path)?;
|
||||||
let city: City = postcard::from_bytes(body)
|
let city: City = postcard::from_bytes(body)
|
||||||
.with_context(|| format!("{}: corrupt or truncated city data", path.display()))?;
|
.with_context(|| format!("{}: corrupt or truncated city data", path.display()))?;
|
||||||
|
if (header.w, header.h) != (city.size.w, city.size.h) {
|
||||||
|
bail!(
|
||||||
|
"{}: header says {}x{} but the city is {}x{}",
|
||||||
|
path.display(),
|
||||||
|
header.w,
|
||||||
|
header.h,
|
||||||
|
city.size.w,
|
||||||
|
city.size.h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let strata_ok = city.strata.len() == citygen::StratumKind::PHYSICAL.len()
|
||||||
|
&& city
|
||||||
|
.strata
|
||||||
|
.iter()
|
||||||
|
.all(|s| s.tiles.size() == city.size && s.light.size() == city.size);
|
||||||
|
if !strata_ok {
|
||||||
|
bail!("{}: strata do not match the city size", path.display());
|
||||||
|
}
|
||||||
|
if let Err(violations) = city.validate() {
|
||||||
|
let first: Vec<String> = violations.iter().take(3).map(|v| v.to_string()).collect();
|
||||||
|
bail!(
|
||||||
|
"{}: city violates its invariants ({} problems, e.g. {})",
|
||||||
|
path.display(),
|
||||||
|
violations.len(),
|
||||||
|
first.join("; ")
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(city)
|
Ok(city)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +193,42 @@ mod tests {
|
|||||||
assert!(list_cities(&dir.join("missing")).unwrap().is_empty());
|
assert!(list_cities(&dir.join("missing")).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_structurally_inconsistent_cities() {
|
||||||
|
let dir = tmp("inconsistent");
|
||||||
|
let p = dir.join("x.city");
|
||||||
|
// Header disagrees with the body.
|
||||||
|
let c = tiny();
|
||||||
|
save_city(&p, &c).unwrap();
|
||||||
|
let mut bytes = std::fs::read(&p).unwrap();
|
||||||
|
let header_len = u32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]) as usize;
|
||||||
|
let mut header: Header = postcard::from_bytes(&bytes[10..10 + header_len]).unwrap();
|
||||||
|
header.w = 96;
|
||||||
|
let hb = postcard::to_allocvec(&header).unwrap();
|
||||||
|
let body = bytes.split_off(10 + header_len);
|
||||||
|
bytes.truncate(6);
|
||||||
|
bytes.extend_from_slice(&(hb.len() as u32).to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&hb);
|
||||||
|
bytes.extend_from_slice(&body);
|
||||||
|
std::fs::write(&p, &bytes).unwrap();
|
||||||
|
let err = load_city(&p).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("header"), "{err}");
|
||||||
|
// Body violates the city invariants (tiles reference districts that do not exist).
|
||||||
|
let mut broken = tiny();
|
||||||
|
broken.districts.clear();
|
||||||
|
save_city(&p, &broken).unwrap();
|
||||||
|
let err = load_city(&p).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("invariant") || err.contains("missing"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
// A grid whose data length disagrees with its size never gets past deserialization.
|
||||||
|
let mut odd = tiny();
|
||||||
|
odd.strata.pop();
|
||||||
|
save_city(&p, &odd).unwrap();
|
||||||
|
assert!(load_city(&p).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_magic_version_and_truncation() {
|
fn rejects_bad_magic_version_and_truncation() {
|
||||||
let dir = tmp("bad");
|
let dir = tmp("bad");
|
||||||
|
|||||||
@@ -164,8 +164,10 @@ impl MapScreen {
|
|||||||
KeyCode::Char('?') => self.help = !self.help,
|
KeyCode::Char('?') => self.help = !self.help,
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
let name = st.city.as_ref().map(|c| c.name.clone())?;
|
let name = st.city.as_ref().map(|c| c.name.clone())?;
|
||||||
|
// The save prompt hands this very screen back, state intact.
|
||||||
|
let live = std::mem::replace(self, MapScreen::explore(st.tick));
|
||||||
return Some(ScreenCmd::Switch {
|
return Some(ScreenCmd::Switch {
|
||||||
to: Box::new(Files::save(st.tick, &name)),
|
to: Box::new(Files::save(st.tick, &name, Some(Box::new(live)))),
|
||||||
fx: TransitionKind::None,
|
fx: TransitionKind::None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -510,12 +512,24 @@ mod tests {
|
|||||||
assert!(buffer_text(t.backend().buffer()).contains("move"));
|
assert!(buffer_text(t.backend().buffer()).contains("move"));
|
||||||
s.update(&mut st, &[key(KeyCode::Esc)]);
|
s.update(&mut st, &[key(KeyCode::Esc)]);
|
||||||
assert!(!s.help);
|
assert!(!s.help);
|
||||||
|
// Saving while walking must bring the walk back, not a fresh explore screen.
|
||||||
|
s.stratum = StratumKind::Undercity;
|
||||||
|
let ScreenCmd::Switch { to: mut files, .. } = s.update(&mut st, &[key(KeyCode::Char('s'))])
|
||||||
|
else {
|
||||||
|
panic!("s should open the save screen");
|
||||||
|
};
|
||||||
|
let ScreenCmd::Switch { to: back, .. } = files.update(&mut st, &[key(KeyCode::Esc)]) else {
|
||||||
|
panic!("esc should return to the map");
|
||||||
|
};
|
||||||
|
st.tick += 500; // let transient statuses expire so the mode hints show
|
||||||
|
t.draw(|f| back.draw(&st, f)).unwrap();
|
||||||
|
let text = buffer_text(t.backend().buffer());
|
||||||
|
assert!(
|
||||||
|
text.contains("tab camera") && text.contains("Undercity"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
s.update(&mut st, &[key(KeyCode::Tab)]);
|
s.update(&mut st, &[key(KeyCode::Tab)]);
|
||||||
assert_eq!(s.mode, Mode::Explore);
|
assert_eq!(s.mode, Mode::Explore);
|
||||||
assert!(matches!(
|
|
||||||
s.update(&mut st, &[key(KeyCode::Char('s'))]),
|
|
||||||
ScreenCmd::Switch { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
s.update(&mut st, &[key(KeyCode::Char('q'))]),
|
s.update(&mut st, &[key(KeyCode::Char('q'))]),
|
||||||
ScreenCmd::Switch { .. }
|
ScreenCmd::Switch { .. }
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub struct Files {
|
|||||||
entries: Vec<CityFileInfo>,
|
entries: Vec<CityFileInfo>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
/// The screen to return to after saving; a fresh explore screen when absent.
|
||||||
|
back: Option<Box<dyn Screen>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `YYYY-MM-DD HH:MM` in UTC, without a date crate (civil-from-days algorithm).
|
/// `YYYY-MM-DD HH:MM` in UTC, without a date crate (civil-from-days algorithm).
|
||||||
@@ -58,8 +60,8 @@ impl Files {
|
|||||||
Self::load_in(default_dir(), _tick)
|
Self::load_in(default_dir(), _tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(tick: u64, name: &str) -> Self {
|
pub fn save(tick: u64, name: &str, back: Option<Box<dyn Screen>>) -> Self {
|
||||||
Self::save_in(default_dir(), tick, name)
|
Self::save_in(default_dir(), tick, name, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_in(dir: PathBuf, _tick: u64) -> Self {
|
pub fn load_in(dir: PathBuf, _tick: u64) -> Self {
|
||||||
@@ -70,10 +72,11 @@ impl Files {
|
|||||||
entries,
|
entries,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
error: None,
|
error: None,
|
||||||
|
back: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_in(dir: PathBuf, _tick: u64, name: &str) -> Self {
|
pub fn save_in(dir: PathBuf, _tick: u64, name: &str, back: Option<Box<dyn Screen>>) -> Self {
|
||||||
let form = Form {
|
let form = Form {
|
||||||
fields: vec![Field::Text {
|
fields: vec![Field::Text {
|
||||||
label: "Name".into(),
|
label: "Name".into(),
|
||||||
@@ -89,12 +92,17 @@ impl Files {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
error: None,
|
error: None,
|
||||||
|
back,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn back_to_map(st: &AppState) -> ScreenCmd {
|
fn back_to_map(&mut self, st: &AppState) -> ScreenCmd {
|
||||||
|
let to = self
|
||||||
|
.back
|
||||||
|
.take()
|
||||||
|
.unwrap_or_else(|| crate::screens::explore_screen(st.tick));
|
||||||
ScreenCmd::Switch {
|
ScreenCmd::Switch {
|
||||||
to: crate::screens::explore_screen(st.tick),
|
to,
|
||||||
fx: TransitionKind::None,
|
fx: TransitionKind::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +149,7 @@ impl Screen for Files {
|
|||||||
},
|
},
|
||||||
Mode::Save(form) => {
|
Mode::Save(form) => {
|
||||||
if k.code == KeyCode::Esc {
|
if k.code == KeyCode::Esc {
|
||||||
return Self::back_to_map(st);
|
return self.back_to_map(st);
|
||||||
}
|
}
|
||||||
if k.code == KeyCode::Enter {
|
if k.code == KeyCode::Enter {
|
||||||
let name = form.text("Name").unwrap_or("").trim().to_string();
|
let name = form.text("Name").unwrap_or("").trim().to_string();
|
||||||
@@ -151,13 +159,13 @@ impl Screen for Files {
|
|||||||
name
|
name
|
||||||
};
|
};
|
||||||
let Some(city) = st.city.as_ref() else {
|
let Some(city) = st.city.as_ref() else {
|
||||||
return Self::back_to_map(st);
|
return self.back_to_map(st);
|
||||||
};
|
};
|
||||||
let path = self.dir.join(format!("{name}.city"));
|
let path = self.dir.join(format!("{name}.city"));
|
||||||
match save_city(&path, city) {
|
match save_city(&path, city) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
st.set_status(format!("saved {}", path.display()), 120);
|
st.set_status(format!("saved {}", path.display()), 120);
|
||||||
return Self::back_to_map(st);
|
return self.back_to_map(st);
|
||||||
}
|
}
|
||||||
Err(e) => self.error = Some(e.to_string()),
|
Err(e) => self.error = Some(e.to_string()),
|
||||||
}
|
}
|
||||||
@@ -299,7 +307,7 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let name = st.city.as_ref().unwrap().name.clone();
|
let name = st.city.as_ref().unwrap().name.clone();
|
||||||
let mut save = Files::save_in(dir.clone(), 0, &name);
|
let mut save = Files::save_in(dir.clone(), 0, &name, None);
|
||||||
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
||||||
t.draw(|f| save.draw(&st, f)).unwrap();
|
t.draw(|f| save.draw(&st, f)).unwrap();
|
||||||
assert!(buffer_text(t.backend().buffer()).contains("SAVE"));
|
assert!(buffer_text(t.backend().buffer()).contains("SAVE"));
|
||||||
@@ -319,6 +327,25 @@ mod tests {
|
|||||||
ScreenCmd::Switch { .. }
|
ScreenCmd::Switch { .. }
|
||||||
));
|
));
|
||||||
assert_eq!(st.city.as_ref().map(|c| c.seed), Some(9));
|
assert_eq!(st.city.as_ref().map(|c| c.seed), Some(9));
|
||||||
|
// Saving from a live map screen hands that same screen back, state intact.
|
||||||
|
let mut map = crate::screens::explore::MapScreen::explore(0);
|
||||||
|
map.mode = crate::screens::explore::Mode::Walk;
|
||||||
|
map.stratum = citygen::StratumKind::Undercity;
|
||||||
|
map.digital = true;
|
||||||
|
st.player = Some(crate::walker::Player::spawn(st.city.as_ref().unwrap(), 1));
|
||||||
|
let mut save = Files::save_in(dir.clone(), 0, "again", Some(Box::new(map)));
|
||||||
|
let ScreenCmd::Switch { to, .. } = save.update(&mut st, &[key(KeyCode::Enter)]) else {
|
||||||
|
panic!("save should switch back");
|
||||||
|
};
|
||||||
|
st.tick += 500; // let the "saved" status expire so the mode hints show
|
||||||
|
let mut t3 = Terminal::new(TestBackend::new(120, 40)).unwrap();
|
||||||
|
t3.draw(|f| to.draw(&st, f)).unwrap();
|
||||||
|
let text = buffer_text(t3.backend().buffer());
|
||||||
|
assert!(
|
||||||
|
text.contains("tab camera") && text.contains("Undercity"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
|
||||||
let mut empty = Files::load_in(tmp("empty"), 0);
|
let mut empty = Files::load_in(tmp("empty"), 0);
|
||||||
t.draw(|f| empty.draw(&st, f)).unwrap();
|
t.draw(|f| empty.draw(&st, f)).unwrap();
|
||||||
assert!(buffer_text(t.backend().buffer()).contains("no saved cities"));
|
assert!(buffer_text(t.backend().buffer()).contains("no saved cities"));
|
||||||
|
|||||||
@@ -189,13 +189,42 @@ impl Dir {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dense row-major grid of `T`.
|
/// Dense row-major grid of `T`. Deserialization rejects data that does not match `size`.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(try_from = "RawGrid<T>")]
|
||||||
|
#[serde(bound(deserialize = "T: Deserialize<'de>"))]
|
||||||
pub struct Grid<T> {
|
pub struct Grid<T> {
|
||||||
size: Size,
|
size: Size,
|
||||||
data: Vec<T>,
|
data: Vec<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The on-disk shape of a grid, before the size/data consistency check.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawGrid<T> {
|
||||||
|
size: Size,
|
||||||
|
data: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> TryFrom<RawGrid<T>> for Grid<T> {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(raw: RawGrid<T>) -> Result<Self, Self::Error> {
|
||||||
|
if raw.data.len() != raw.size.area() {
|
||||||
|
return Err(format!(
|
||||||
|
"grid of {}x{} needs {} cells, found {}",
|
||||||
|
raw.size.w,
|
||||||
|
raw.size.h,
|
||||||
|
raw.size.area(),
|
||||||
|
raw.data.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Grid {
|
||||||
|
size: raw.size,
|
||||||
|
data: raw.data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: Clone> Grid<T> {
|
impl<T: Clone> Grid<T> {
|
||||||
pub fn filled(size: Size, v: T) -> Self {
|
pub fn filled(size: Size, v: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -308,6 +337,24 @@ mod tests {
|
|||||||
assert_eq!(g.count(|&v| v == 9), 4);
|
assert_eq!(g.count(|&v| v == 9), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_rejects_inconsistent_serialized_data() {
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Fake {
|
||||||
|
size: Size,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
let bad = ron::to_string(&Fake {
|
||||||
|
size: Size { w: 4, h: 3 },
|
||||||
|
data: vec![0; 5],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(ron::from_str::<Grid<u8>>(&bad).is_err());
|
||||||
|
let good = ron::to_string(&Grid::filled(Size { w: 4, h: 3 }, 7u8)).unwrap();
|
||||||
|
let g: Grid<u8> = ron::from_str(&good).unwrap();
|
||||||
|
assert_eq!(g.count(|&v| v == 7), 12);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dir_delta_opposite() {
|
fn dir_delta_opposite() {
|
||||||
assert_eq!(Dir::N.delta(), (0, -1));
|
assert_eq!(Dir::N.delta(), (0, -1));
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ impl City {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for link in &self.net.links {
|
||||||
|
for id in [link.0, link.1] {
|
||||||
|
if id.index() >= self.net.nodes.len() {
|
||||||
|
out.push(Violation::NetAnchor(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for d in &self.districts {
|
for d in &self.districts {
|
||||||
let has_building = self.buildings.iter().any(|b| b.district == d.id);
|
let has_building = self.buildings.iter().any(|b| b.district == d.id);
|
||||||
let has_public = self.connectors.iter().any(|c| {
|
let has_public = self.connectors.iter().any(|c| {
|
||||||
|
|||||||
Reference in New Issue
Block a user