use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use std::time::Duration;
use assert_cmd::Command;
use foglet_game::{WorldDb, WORLD_TICK_TASKS_MIGRATION};
use predicates::str::contains;
const GAME_TOML_V4_TICK_FIXTURE: &str = r#"
[game]
title = "Tick Test"
slug = "tick-smoke"
description = "A world-tick smoke fixture."
min_width = 80
min_height = 24
start_map = "lobby"
start_x = 0
start_y = 0
[world]
enabled = true
path = "world/world.sqlite"
[world_ticks]
enabled = true
max_catchup_per_call = 1
"#;
const V4_WORLD_MIGRATIONS_DIR: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/v4-world-migrations"
);
fn sorted_sql_migration_files(dir: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)
.unwrap_or_else(|error| panic!("read migration directory `{}`: {error}", dir.display()))
.map(|entry| {
entry
.unwrap_or_else(|error| {
panic!("read migration entry in `{}`: {error}", dir.display())
})
.path()
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sql"))
.collect();
files.sort();
files
}
fn apply_migration_directory(world: &WorldDb, migration_dir: &Path) {
for sql_path in sorted_sql_migration_files(migration_dir) {
let sql = fs::read_to_string(&sql_path)
.unwrap_or_else(|error| panic!("read `{}`: {error}", sql_path.display()));
world
.connection()
.execute_batch(&sql)
.unwrap_or_else(|error| panic!("apply `{}`: {error}", sql_path.display()));
}
}
fn write_fixture_project(project: &std::path::Path) {
let assets = project.join("assets");
fs::create_dir_all(&assets).expect("assets dir exists");
fs::write(assets.join("game.toml"), GAME_TOML_V4_TICK_FIXTURE).expect("write game config");
}
#[test]
fn fgk_tick_executes_due_world_tasks_once() {
let td = tempfile::tempdir().expect("tempdir");
let project = td.path().join("tick-project");
fs::create_dir_all(&project).expect("create project dir");
write_fixture_project(&project);
let world_path = project.join("world").join("world.sqlite");
let mut world = WorldDb::open(&world_path).expect("seed world db");
world
.apply_migration(&WORLD_TICK_TASKS_MIGRATION)
.expect("world tick migration exists");
world
.register_tick("station_restock", 600, |_tx| Ok(()))
.expect("seed station task");
world
.register_tick("patrol_restart", 600, |_tx| Ok(()))
.expect("seed patrol task");
Command::cargo_bin("fgk")
.unwrap()
.arg("tick")
.arg("--project")
.arg(&project)
.assert()
.success()
.stdout(contains("Ran 1 task(s), skipped 1 task(s)"));
let rows_due: i64 = world
.connection()
.query_row(
"SELECT COUNT(*) FROM world_tick_tasks WHERE last_run_at IS NOT NULL",
[],
|row| row.get(0),
)
.expect("count query");
assert_eq!(rows_due, 1, "catch-up bound should leave one task pending");
}
#[test]
fn fgk_tick_reports_missing_world_db_and_does_not_create_file() {
let td = tempfile::tempdir().expect("tempdir");
let project = td.path().join("missing-world");
fs::create_dir_all(&project).expect("create project dir");
write_fixture_project(&project);
let world_path = project.join("world").join("world.sqlite");
assert!(
!world_path.exists(),
"fixture should start with no world db"
);
Command::cargo_bin("fgk")
.unwrap()
.arg("tick")
.arg("--project")
.arg(&project)
.assert()
.failure()
.stderr(contains("world db is missing at"))
.stderr(contains(world_path.to_string_lossy()));
assert!(
!world_path.exists(),
"failure should not create missing world db"
);
assert!(
!project.join("world").exists(),
"failure should not create world directory"
);
}
#[test]
fn fgk_tick_can_be_interrupted_during_a_callback_without_advancing_last_run_at() {
let td = tempfile::tempdir().expect("tempdir");
let project = td.path().join("interruption-project");
fs::create_dir_all(&project).expect("create project dir");
write_fixture_project(&project);
let world_path = project.join("world").join("world.sqlite");
let mut world = WorldDb::open(&world_path).expect("seed world db");
world
.apply_migration(&WORLD_TICK_TASKS_MIGRATION)
.expect("world tick migration exists");
world
.register_tick("interruption_tick", 600, |_tx| Ok(()))
.expect("seed interruption task");
let mut command = StdCommand::new(env!("CARGO_BIN_EXE_fgk"));
command
.arg("tick")
.arg("--project")
.arg(&project)
.env("FGK_TICK_TEST_CALLBACK_DELAY_MS", "2000");
let mut child = command.spawn().expect("spawn fgk tick command");
std::thread::sleep(Duration::from_millis(250));
assert!(
child
.try_wait()
.expect("checking fgk tick child status")
.is_none(),
"fgk tick should still be running while interruption test waits"
);
#[cfg(unix)]
{
let signal = std::process::Command::new("kill")
.arg("-INT")
.arg(child.id().to_string())
.status()
.expect("send Ctrl-C to fgk tick");
assert!(signal.success(), "SIGINT dispatch should work");
}
#[cfg(not(unix))]
{
child.kill().expect("terminate fgk tick command");
}
let output = child.wait_with_output().expect("wait for interrupted tick");
assert!(
!output.status.success(),
"interrupted command should not succeed"
);
let last_run: Option<String> = world
.connection()
.query_row(
"SELECT last_run_at FROM world_tick_tasks WHERE key = ?1",
["interruption_tick"],
|row| row.get(0),
)
.expect("query last_run_at");
assert!(
last_run.is_none(),
"interrupted callback must not advance last_run_at"
);
}
#[test]
fn v4_migration_directory_initializes_all_required_tables_in_fresh_world_db() {
let migration_dir = Path::new(V4_WORLD_MIGRATIONS_DIR);
assert!(
migration_dir.is_dir(),
"v4 migration fixture directory must exist at `{}`",
migration_dir.display()
);
let files = sorted_sql_migration_files(migration_dir);
for required in [
"0010_create_places.sql",
"0011_create_routes.sql",
"0012_create_presence.sql",
"0013_create_place_recall.sql",
"0014_create_inventory_slots.sql",
"0015_create_world_tick_tasks.sql",
] {
assert!(
files
.iter()
.any(|path| { path.file_name().and_then(|name| name.to_str()) == Some(required) }),
"migration fixture should contain `{required}`"
);
}
let td = tempfile::tempdir().expect("tempdir");
let world_path = td.path().join("fresh-world.sqlite");
let world = WorldDb::open(&world_path).expect("open fresh world db");
let pre_apply_count: i64 = world
.connection()
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'table' \
AND name IN ('places','routes','presence','place_recall','inventory_slots','world_tick_tasks')",
[],
|row| row.get(0),
)
.expect("count tables before applying fixture");
assert_eq!(
pre_apply_count, 0,
"fresh world should not have kit tables before applying fixture migrations"
);
apply_migration_directory(&world, migration_dir);
let mut stmt = world
.connection()
.prepare(
"SELECT name FROM sqlite_master \
WHERE type = 'table' \
AND name IN ('places','routes','presence','place_recall','inventory_slots','world_tick_tasks') \
ORDER BY name",
)
.expect("prepare table-list query");
let names: Vec<String> = stmt
.query_map([], |row| row.get(0))
.expect("query table names")
.collect::<Result<Vec<_>, _>>()
.expect("collect table names");
assert_eq!(
names,
vec![
"inventory_slots".to_string(),
"place_recall".to_string(),
"places".to_string(),
"presence".to_string(),
"routes".to_string(),
"world_tick_tasks".to_string()
],
"all required tables should exist after applying migration directory fixture"
);
}