use crate::common;
use common::copy_fixture_to_temp;
use pasta_lua::loader::{
GhostConfig, LoaderError, PastaConfig, PastaLoader, default_hour_margin, default_spot_newlines,
default_talk_interval_max, default_talk_interval_min,
};
use std::path::PathBuf;
fn read_pasta_config_ghost_number(runtime: &pasta_lua::PastaLuaRuntime, key: &str) -> f64 {
let script = format!(
r#"
local config = require("@pasta_config")
return config.ghost.{key}
"#
);
let result = runtime.exec(&script).unwrap();
result
.as_f64()
.or_else(|| result.as_i64().map(|v| v as f64))
.unwrap_or_else(|| panic!("@pasta_config.ghost.{key} should be a number"))
}
#[test]
fn minimal_actor_only_starts_and_loads_dic_directly() {
let temp = copy_fixture_to_temp("minimal_actor_only");
let runtime = PastaLoader::load(temp.path()).unwrap();
let alive = runtime.exec("return 1 + 1").unwrap();
assert_eq!(alive.as_i64(), Some(2));
let scene_dic_path = temp
.path()
.join("profile/pasta/cache/lua/pasta/scene_dic.lua");
let scene_dic = std::fs::read_to_string(&scene_dic_path)
.expect("scene_dic.lua should be generated on load");
assert!(
scene_dic.contains("pasta.scene.talk"),
"dic/ 直下の talk.pasta が既定 glob で登録されていない: {scene_dic}"
);
}
#[test]
fn minimal_and_full_ghost_are_equivalent_in_rust() {
let minimal = PastaConfig::from_str("[actor.\"女の子\"]\nspot = 0\n").unwrap();
let full = PastaConfig::from_str(
"[actor.\"女の子\"]\nspot = 0\n\
[ghost]\n\
talk_interval_min = 180\n\
talk_interval_max = 300\n\
hour_margin = 30\n\
spot_newlines = 1.5\n",
)
.unwrap();
let minimal_ghost = minimal
.custom_fields
.get("ghost")
.and_then(toml::Value::as_table)
.expect("ghost should be materialized for minimal config");
let full_ghost = full
.custom_fields
.get("ghost")
.and_then(toml::Value::as_table)
.expect("ghost should be present for full config");
assert_eq!(
minimal_ghost, full_ghost,
"最小構成の補完済み ghost とフル記述の ghost が一致しない"
);
}
#[test]
fn minimal_and_full_expose_same_ghost_to_lua() {
let minimal_temp = copy_fixture_to_temp("minimal_actor_only");
let minimal_runtime = PastaLoader::load(minimal_temp.path()).unwrap();
assert_eq!(
read_pasta_config_ghost_number(&minimal_runtime, "spot_newlines"),
default_spot_newlines(),
);
assert_eq!(
read_pasta_config_ghost_number(&minimal_runtime, "talk_interval_min"),
default_talk_interval_min() as f64,
);
assert_eq!(
read_pasta_config_ghost_number(&minimal_runtime, "talk_interval_max"),
default_talk_interval_max() as f64,
);
assert_eq!(
read_pasta_config_ghost_number(&minimal_runtime, "hour_margin"),
default_hour_margin() as f64,
);
let full_temp = copy_fixture_to_temp("minimal_actor_only");
std::fs::write(
full_temp.path().join("pasta.toml"),
"[actor.\"女の子\"]\nspot = 0\n\
[actor.\"男の子\"]\nspot = 1\n\
[ghost]\n\
talk_interval_min = 180\n\
talk_interval_max = 300\n\
hour_margin = 30\n\
spot_newlines = 1.5\n",
)
.unwrap();
let full_runtime = PastaLoader::load(full_temp.path()).unwrap();
for key in ["spot_newlines", "talk_interval_min", "talk_interval_max", "hour_margin"] {
assert_eq!(
read_pasta_config_ghost_number(&minimal_runtime, key),
read_pasta_config_ghost_number(&full_runtime, key),
"最小/フルで @pasta_config.ghost.{key} が一致しない",
);
}
}
#[tracing_test::traced_test]
#[test]
fn actor_absence_warns_without_halting_startup() {
let temp = copy_fixture_to_temp("minimal_actor_only");
std::fs::write(
temp.path().join("pasta.toml"),
"[ghost]\ntalk_interval_min = 120\n",
)
.unwrap();
let runtime = PastaLoader::load(temp.path()).unwrap();
let alive = runtime.exec("return 7").unwrap();
assert_eq!(alive.as_i64(), Some(7));
assert!(
logs_contain("No [actor] section is defined"),
"actor 不在時には起動を妨げない警告が発火するべき (R2.3)"
);
}
#[test]
fn ghost_config_default_is_ssot_anchor() {
let d = GhostConfig::default();
assert_eq!(d.talk_interval_min, 180);
assert_eq!(d.talk_interval_max, 300);
assert_eq!(d.hour_margin, 30);
assert_eq!(d.spot_newlines, 1.5);
assert_eq!(default_talk_interval_min(), d.talk_interval_min);
assert_eq!(default_talk_interval_max(), d.talk_interval_max);
assert_eq!(default_hour_margin(), d.hour_margin);
assert_eq!(default_spot_newlines(), d.spot_newlines);
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .and_then(|p| p.parent()) .expect("repo root should resolve from CARGO_MANIFEST_DIR")
.to_path_buf()
}
#[test]
fn config_reference_doc_matches_ssot() {
let doc_path = repo_root()
.join(".claude/skills/pasta-ghost-authoring/references/pasta-toml.md");
let doc = std::fs::read_to_string(&doc_path)
.unwrap_or_else(|e| panic!("config reference doc not readable at {doc_path:?}: {e}"));
let d = GhostConfig::default();
let expect_row = |key: &str, value: String| {
let has = doc.lines().any(|line| {
line.contains(key) && line.contains(&format!("`{value}`"))
});
assert!(
has,
"config reference doc の `{key}` 行に SSOT 値 `{value}` が見つからない (R5.4/R5.5)"
);
};
expect_row("talk_interval_min", d.talk_interval_min.to_string());
expect_row("talk_interval_max", d.talk_interval_max.to_string());
expect_row("hour_margin", d.hour_margin.to_string());
expect_row("spot_newlines", format!("{:?}", d.spot_newlines));
}
#[test]
fn lua_fallback_literals_match_ssot() {
let scripts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("pasta_scripts");
let d = GhostConfig::default();
let act_lua = std::fs::read_to_string(scripts.join("pasta/shiori/act.lua"))
.expect("act.lua should be readable");
let spot_default = format!("{:?}", d.spot_newlines); assert!(
act_lua.contains(&format!("\"spot_newlines\", {spot_default}")),
"act.lua の spot_newlines フォールバックリテラルが SSOT ({spot_default}) と不一致 (R5.5)"
);
let vd_lua = std::fs::read_to_string(scripts.join("pasta/shiori/event/virtual_dispatcher.lua"))
.expect("virtual_dispatcher.lua should be readable");
assert!(
vd_lua.contains(&format!(
"\"talk_interval_min\", {}",
d.talk_interval_min
)),
"virtual_dispatcher.lua の talk_interval_min フォールバック ({}) が SSOT と不一致 (R5.5)",
d.talk_interval_min
);
assert!(
vd_lua.contains(&format!(
"\"talk_interval_max\", {}",
d.talk_interval_max
)),
"virtual_dispatcher.lua の talk_interval_max フォールバック ({}) が SSOT と不一致 (R5.5)",
d.talk_interval_max
);
assert!(
vd_lua.contains(&format!("ghost.hour_margin or {}", d.hour_margin)),
"virtual_dispatcher.lua の hour_margin フォールバック ({}) が SSOT と不一致 (R5.5)",
d.hour_margin
);
}
const ACTOR_ABSENCE_PHRASE: &str = "No [actor] section is defined";
const LEGACY_FULL_CONFIG: &str = "\
[package]\n\
name = \"hello-pasta\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\
\n\
[actor.\"女の子\"]\n\
spot = 0\n\
\n\
[actor.\"男の子\"]\n\
spot = 1\n\
\n\
[ghost]\n\
talk_interval_min = 90\n\
talk_interval_max = 200\n\
hour_margin = 15\n\
spot_newlines = 2.5\n\
\n\
[loader]\n\
debug_mode = true\n\
\n\
[talk]\n\
script_wait_normal = 40\n";
#[tracing_test::traced_test]
#[test]
fn legacy_full_config_with_package_loads_cleanly() {
let config = PastaConfig::from_str(LEGACY_FULL_CONFIG)
.expect("legacy full config with [package] must parse without error (R6.1/R6.2)");
let package = config
.custom_fields
.get("package")
.and_then(toml::Value::as_table)
.expect("[package] should be preserved in custom_fields (R4.5/R6.2)");
assert_eq!(
package.get("name").and_then(toml::Value::as_str),
Some("hello-pasta"),
"[package].name は無改変で保持されるべき"
);
assert_eq!(
package.get("version").and_then(toml::Value::as_str),
Some("0.1.0"),
);
assert_eq!(
package.get("edition").and_then(toml::Value::as_str),
Some("2021"),
);
let ghost = config
.custom_fields
.get("ghost")
.and_then(toml::Value::as_table)
.expect("[ghost] should be present");
assert_eq!(
ghost.get("talk_interval_min").and_then(toml::Value::as_integer),
Some(90),
"明示 ghost 値が補完で上書きされてはならない (R6.1)"
);
assert_eq!(
ghost.get("talk_interval_max").and_then(toml::Value::as_integer),
Some(200),
);
assert_eq!(
ghost.get("hour_margin").and_then(toml::Value::as_integer),
Some(15),
);
assert_eq!(
ghost.get("spot_newlines").and_then(toml::Value::as_float),
Some(2.5),
);
assert!(
!logs_contain(ACTOR_ABSENCE_PHRASE),
"[actor] を含むフル記述で actor 不在警告が出てはならない (R6.2)"
);
}
#[tracing_test::traced_test]
#[test]
fn legacy_full_config_starts_runtime_without_warning() {
let temp = copy_fixture_to_temp("minimal_actor_only");
std::fs::write(temp.path().join("pasta.toml"), LEGACY_FULL_CONFIG).unwrap();
let runtime = PastaLoader::load(temp.path())
.expect("legacy full config must start a real runtime (R6.1/R6.3)");
let alive = runtime.exec("return 21 + 21").unwrap();
assert_eq!(alive.as_i64(), Some(42));
assert_eq!(
read_pasta_config_ghost_number(&runtime, "talk_interval_min"),
90.0,
"明示 ghost 値が Lua 露出時に上書きされてはならない (R6.1)"
);
assert_eq!(
read_pasta_config_ghost_number(&runtime, "spot_newlines"),
2.5,
);
assert!(
!logs_contain(ACTOR_ABSENCE_PHRASE),
"[actor] を含むフル記述の実起動で actor 不在警告が出てはならない (R6.2)"
);
}
#[test]
fn missing_pasta_toml_is_rejected_with_config_not_found() {
let temp = tempfile::TempDir::new().unwrap();
let err = PastaConfig::load(temp.path())
.expect_err("file absence must be rejected, not tolerated (R3.5)");
assert!(
matches!(err, LoaderError::ConfigNotFound(_)),
"ファイル不在は既存の ConfigNotFound で弾かれるべき (R3.5)。実際: {err:?}"
);
}