1use foch_test::compile::Bundle;
10
11pub const MAX_PATH: usize = 260;
13
14pub fn fits_path_budget(runtime_dir_len: usize, longest_relative: usize) -> bool {
19 runtime_dir_len + longest_relative + 2 <= MAX_PATH
21}
22
23pub fn command_file(bundle: &Bundle, disable_ai: bool) -> String {
26 let base = bundle
27 .files
28 .get("commands.txt")
29 .map(String::as_str)
30 .unwrap_or_default();
31 if disable_ai {
32 format!("ai\n{base}")
33 } else {
34 base.to_string()
35 }
36}
37
38pub fn isolated_settings(template: &str) -> String {
42 let mut out = String::with_capacity(template.len());
43 for line in template.split_inclusive('\n') {
44 let trimmed = line.trim_start();
45 let indent = &line[..line.len() - trimmed.len()];
46 let rewritten = if let Some((key, _)) = trimmed.split_once('=') {
47 match key.trim() {
48 "fullScreen" => Some(format!("{indent}fullScreen=no\n")),
49 "borderless" => Some(format!("{indent}borderless=no\n")),
50 "master_volume" | "music_volume" | "sound_fx_volume" | "ambient_volume"
51 | "dev_master_volume" => Some(format!("{indent}{}=0.000000\n", key.trim())),
52 _ => None,
53 }
54 } else {
55 None
56 };
57 out.push_str(&rewritten.unwrap_or_else(|| line.to_string()));
58 }
59 if !out.ends_with('\n') {
60 out.push('\n');
61 }
62 out
63}
64
65pub fn dlc_load(mod_files: &[&str]) -> String {
67 let mods: Vec<String> = mod_files
68 .iter()
69 .map(|name| format!("\"mod/{name}\""))
70 .collect();
71 format!(
72 "{{\"enabled_mods\":[{}],\"disabled_dlcs\":[]}}",
73 mods.join(",")
74 )
75}
76
77pub fn all_cases_ended(log: &str, bundle: &Bundle) -> bool {
80 let marker = &bundle.marker_prefix;
81 bundle.cases.iter().all(|&case| {
82 let needle = format!("{} END", bundle.case_prefix(case));
83 log.lines()
84 .any(|line| line.contains(marker.as_str()) && line.trim_end().ends_with(&needle))
85 })
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum LayerAction {
93 Copy,
94 Link,
95 Skip,
96}
97
98pub fn classify_entry(name: &str, is_dir: bool) -> LayerAction {
101 if is_dir {
102 return LayerAction::Link;
103 }
104 let lower = name.to_ascii_lowercase();
105 let skip = matches!(
106 lower.as_str(),
107 "universal-checksum-patcher.exe" | "console_history.txt" | "userdir.txt"
108 );
109 if skip {
110 LayerAction::Skip
111 } else {
112 LayerAction::Copy
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use foch_test::compile::{Bundle, Launch, Requirements};
120 use foch_test::model::{AiMode, CaseIndex, GameDate, RunId, Tag};
121 use foch_test::plan::SessionId;
122 use std::collections::BTreeMap;
123
124 fn bundle() -> Bundle {
125 let date = GameDate::parse("1444.11.11").unwrap();
126 Bundle {
127 protocol: 2,
128 run_id: RunId::parse("run-1").unwrap(),
129 mod_name: "demo".into(),
130 session: SessionId(0),
131 cases: vec![CaseIndex(0), CaseIndex(2)],
132 marker_prefix: "FOCH_TEST_V2 run-1 abc123".into(),
133 namespace: "foch_test_demo_abc123".into(),
134 test_mod_name: "demo tests [abc123]".into(),
135 files: BTreeMap::from([(
136 "commands.txt".to_string(),
137 "speed 0\nrun start.txt\nspeed 5\n".to_string(),
138 )]),
139 launch: Launch {
140 args: vec!["-start_tag=SWE".into()],
141 ai: AiMode::Off,
142 player: Tag::parse("SWE").unwrap(),
143 start_date: date,
144 end_date: date,
145 },
146 checks: Vec::new(),
147 windows: Vec::new(),
148 requires: Requirements {
149 start_date: date,
150 ai_off: true,
151 shared_session: false,
152 },
153 }
154 }
155
156 #[test]
157 fn path_budget_matches_windows_max_path() {
158 assert!(fits_path_budget(131, 127));
160 assert!(!fits_path_budget(132, 127));
161 }
162
163 #[test]
164 fn command_file_prepends_ai_only_when_disabling() {
165 let bundle = bundle();
166 assert_eq!(
167 command_file(&bundle, false),
168 "speed 0\nrun start.txt\nspeed 5\n"
169 );
170 assert_eq!(
171 command_file(&bundle, true),
172 "ai\nspeed 0\nrun start.txt\nspeed 5\n"
173 );
174 }
175
176 #[test]
177 fn settings_are_windowed_and_muted_preserving_other_keys() {
178 let template = "graphics={\n\tfullScreen=yes\n\tborderless=yes\n\tsize={ x=1920 }\n}\nmaster_volume=100.000000\nmusic_volume=50.000000\ndifficulty=\"LUCK_NONE\"\n";
179 let out = isolated_settings(template);
180 assert!(out.contains("\tfullScreen=no\n"));
181 assert!(out.contains("\tborderless=no\n"));
182 assert!(out.contains("master_volume=0.000000\n"));
183 assert!(out.contains("music_volume=0.000000\n"));
184 assert!(
185 out.contains("\tsize={ x=1920 }\n"),
186 "unrelated keys preserved"
187 );
188 assert!(out.contains("difficulty=\"LUCK_NONE\"\n"));
189 }
190
191 #[test]
192 fn all_cases_ended_requires_every_case() {
193 let bundle = bundle();
194 let line = |case: usize, record: &str| {
195 format!(
196 "[x]: EVENT [1444.11.11]:{} c{case} {record}\n",
197 bundle.marker_prefix
198 )
199 };
200 let mut log = String::new();
201 log.push_str(&line(0, "END"));
202 assert!(!all_cases_ended(&log, &bundle), "case 2 has not ended");
203 log.push_str(&line(2, "PASS scope"));
204 assert!(!all_cases_ended(&log, &bundle));
205 log.push_str(&line(2, "END"));
206 assert!(all_cases_ended(&log, &bundle));
207 }
208
209 #[test]
210 fn layer_classification_copies_files_links_dirs_skips_noise() {
211 assert_eq!(classify_entry("eu4.exe", false), LayerAction::Copy);
212 assert_eq!(classify_entry("d3d9.dll", false), LayerAction::Copy);
213 assert_eq!(classify_entry("common", true), LayerAction::Link);
214 assert_eq!(
215 classify_entry("console_history.txt", false),
216 LayerAction::Skip
217 );
218 assert_eq!(classify_entry("userdir.txt", false), LayerAction::Skip);
219 }
220
221 #[test]
222 fn dlc_load_enables_source_and_test_mods() {
223 assert_eq!(
224 dlc_load(&["source_mod.mod", "test_mod.mod"]),
225 "{\"enabled_mods\":[\"mod/source_mod.mod\",\"mod/test_mod.mod\"],\"disabled_dlcs\":[]}"
226 );
227 }
228}