BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / session.rs

session.rs

session.rs 31.0 KiB Download Raw
1//! Project navigation, dirty state and equipment records.
2//!
3//! The screen starts small. A project opens on Bodies. Equipment is one
4//! step down from Body. Chosen files are copied into the project so a later
5//! save stays inside the directory.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::equipment::Preset;
11use serde::{Deserialize, Serialize};
12
13use crate::project::{
14 create_project, portable_relative, save_project, OpenedProject, ProjectError, ProjectFile,
15 MANIFEST_NAME,
16};
17
18const MALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-male.glb");
19const FEMALE_GLB: &[u8] = include_bytes!("../tests/fixtures/bodies/test-body-female.glb");
20
21pub const MALE_ASSET: &str = "assets/body/test-body-male.glb";
22pub const FEMALE_ASSET: &str = "assets/body/test-body-female.glb";
23
24pub const START_WINDOW: [f32; 2] = [480.0, 280.0];
25pub const PROJECT_WINDOW: [f32; 2] = [1100.0, 720.0];
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Sex {
29 Male,
30 Female,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Screen {
35 Start,
36 Bodies,
37 Body(Sex),
38 EquipmentList,
39 Equipment(usize),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Intent {
44 Quit,
45 PickNew,
46 PickLoad,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Choice {
51 Save,
52 Discard,
53 Cancel,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum AfterChoice {
58 Stay,
59 SaveThen(Intent),
60 Go(Intent),
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct EquipmentFile {
66 name: String,
67 model: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct EquipmentItem {
72 pub slug: String,
73 pub model: Option<String>,
74 pub legacy_path: Option<String>,
75 pub preset: Option<Preset>,
76 pub error: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct Session {
81 pub screen: Screen,
82 pub active_body: Option<Sex>,
83 pub opened: Option<OpenedProject>,
84 pub saved_document: Option<ProjectFile>,
85 pub equipment: Vec<EquipmentItem>,
86 pub saved_equipment: Vec<EquipmentItem>,
87 pub confirm: Option<Intent>,
88 pub notice: String,
89 pub notice_ok: bool,
90}
91
92impl Session {
93 pub fn new() -> Self {
94 Self {
95 screen: Screen::Start,
96 active_body: None,
97 opened: None,
98 saved_document: None,
99 equipment: Vec::new(),
100 saved_equipment: Vec::new(),
101 confirm: None,
102 notice: "Laad een project of maak een nieuwe projectmap.".to_string(),
103 notice_ok: true,
104 }
105 }
106
107 pub fn shell_size(&self) -> [f32; 2] {
108 match self.screen {
109 Screen::Start => START_WINDOW,
110 _ => PROJECT_WINDOW,
111 }
112 }
113
114 pub fn is_dirty(&self) -> bool {
115 self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()
116 || self.equipment != self.saved_equipment
117 }
118
119 pub fn gate(&mut self, intent: Intent) -> bool {
120 if self.is_dirty() {
121 self.confirm = Some(intent);
122 false
123 } else {
124 true
125 }
126 }
127
128 pub fn resolve(&mut self, choice: Choice) -> AfterChoice {
129 let Some(intent) = self.confirm.take() else {
130 return AfterChoice::Stay;
131 };
132 match choice {
133 Choice::Cancel => {
134 self.confirm = None;
135 AfterChoice::Stay
136 }
137 Choice::Discard => AfterChoice::Go(intent),
138 Choice::Save => {
139 self.confirm = Some(intent);
140 AfterChoice::SaveThen(intent)
141 }
142 }
143 }
144
145 pub fn note_saved(&mut self) {
146 if let Some(opened) = &self.opened {
147 self.saved_document = Some(opened.document.clone());
148 }
149 self.saved_equipment = self.equipment.clone();
150 self.confirm = None;
151 }
152
153 pub fn show_bodies(&mut self) {
154 if self.opened.is_some() {
155 self.screen = Screen::Bodies;
156 }
157 }
158
159 pub fn show_body(&mut self, sex: Sex) {
160 if self.opened.is_some() {
161 self.active_body = Some(sex);
162 self.screen = Screen::Body(sex);
163 }
164 }
165
166 pub fn show_equipment_list(&mut self) {
167 if self.opened.is_some() {
168 self.screen = Screen::EquipmentList;
169 }
170 }
171
172 pub fn show_equipment(&mut self, index: usize) {
173 if index < self.equipment.len() {
174 self.screen = Screen::Equipment(index);
175 }
176 }
177
178 pub fn back(&mut self) {
179 self.screen = match self.screen {
180 Screen::Body(_) => Screen::Bodies,
181 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
182 Screen::Equipment(_) => Screen::EquipmentList,
183 other => other,
184 };
185 }
186
187 pub fn set_name(&mut self, name: String) {
188 if let Some(opened) = &mut self.opened {
189 opened.document.name = name;
190 }
191 }
192
193 pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {
194 if let Some(opened) = &mut self.opened {
195 opened.document.render.target_width = width;
196 opened.document.render.target_height = height;
197 opened.document.render.fps = fps;
198 }
199 }
200
201 pub fn body_path(&self, sex: Sex) -> Option<&str> {
202 let document = &self.opened.as_ref()?.document;
203 match sex {
204 Sex::Male => document.male_model.as_deref(),
205 Sex::Female => document.female_model.as_deref(),
206 }
207 }
208
209 pub fn set_body_path(&mut self, sex: Sex, relative: String) {
210 let Some(opened) = &mut self.opened else {
211 return;
212 };
213 let slot = match sex {
214 Sex::Male => &mut opened.document.male_model,
215 Sex::Female => &mut opened.document.female_model,
216 };
217 *slot = Some(relative);
218 }
219
220 pub fn add_equipment(&mut self) -> usize {
221 let slug = next_slug(&self.equipment);
222 self.equipment.push(EquipmentItem {
223 preset: Some(Preset::new(slug.clone())),
224 error: None,
225 slug,
226 model: None,
227 legacy_path: None,
228 });
229 let index = self.equipment.len() - 1;
230 self.screen = Screen::Equipment(index);
231 index
232 }
233
234 pub fn ensure_preset_budget(
235 &self,
236 replacement: Option<usize>,
237 candidate: &Preset,
238 ) -> Result<(), String> {
239 if replacement.is_none() && self.equipment.len() >= crate::project::MAX_PROJECT_PRESETS {
240 return Err("maximaal 64 equipmentpresets per project".into());
241 }
242 let mut budget = MAX_PROJECT_PRESET_BYTES
243 .checked_sub(candidate.encoded_len()?)
244 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
245 for (i, item) in self.equipment.iter().enumerate() {
246 if Some(i) != replacement {
247 if let Some(p) = &item.preset {
248 budget = budget
249 .checked_sub(p.encoded_len()?)
250 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
251 }
252 }
253 }
254 Ok(())
255 }
256 pub fn available_equipment_name(&self, base: &str) -> Result<String, String> {
257 if !valid_slug(base) {
258 return Err("ongeldige equipmentnaam".into());
259 }
260 let mut name = base.to_string();
261 let mut n = 2;
262 while self.equipment.iter().any(|i| i.slug == name) {
263 let suffix = format!("-{n}");
264 name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix);
265 n += 1;
266 }
267 Ok(name)
268 }
269
270 pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
271 let slug = slug.trim().to_string();
272 if !valid_slug(&slug) {
273 return Err(
274 "de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"
275 .to_string(),
276 );
277 }
278 if self
279 .equipment
280 .iter()
281 .enumerate()
282 .any(|(other, item)| other != index && item.slug == slug)
283 {
284 return Err("die equipmentnaam bestaat al".to_string());
285 }
286 if let Some(item) = self.equipment.get_mut(index) {
287 item.slug = slug;
288 item.legacy_path = None;
289 }
290 Ok(())
291 }
292
293 pub fn set_equipment_model(&mut self, index: usize, relative: String) {
294 if let Some(item) = self.equipment.get_mut(index) {
295 item.model = Some(relative);
296 item.legacy_path = None;
297 item.preset = None;
298 item.error = None;
299 }
300 }
301
302 pub fn delete_equipment(&mut self, index: usize) {
303 if index < self.equipment.len() {
304 self.equipment.remove(index);
305 self.screen = Screen::EquipmentList;
306 }
307 }
308
309 pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {
310 let opened = create_seeded_project(root)?;
311 self.install_opened(opened);
312 self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");
313 Ok(())
314 }
315
316 pub fn install_loaded(&mut self, opened: OpenedProject) {
317 let root = opened
318 .manifest_path
319 .parent()
320 .map(Path::to_path_buf)
321 .unwrap_or_default();
322 let equipment = read_equipment(&root, &opened.document.equipment_presets);
323 self.equipment = equipment.clone();
324 self.saved_equipment = equipment;
325 self.saved_document = Some(opened.document.clone());
326 self.opened = Some(opened);
327 self.screen = Screen::Bodies;
328 self.active_body = None;
329 self.confirm = None;
330 self.ok("Project geopend.");
331 }
332
333 fn install_opened(&mut self, opened: OpenedProject) {
334 self.equipment.clear();
335 self.saved_equipment.clear();
336 self.saved_document = Some(opened.document.clone());
337 self.opened = Some(opened);
338 self.screen = Screen::Bodies;
339 self.active_body = None;
340 self.confirm = None;
341 }
342
343 pub fn save(&mut self) -> Result<(), ProjectError> {
344 let Some(opened) = self.opened.clone() else {
345 return Err(ProjectError::Validation(vec![
346 "er is geen project om op te slaan".to_string(),
347 ]));
348 };
349 let root = opened
350 .manifest_path
351 .parent()
352 .ok_or_else(|| ProjectError::Io {
353 path: opened.manifest_path.display().to_string(),
354 message: "het .ppd-bestand heeft geen projectmap".to_string(),
355 })?;
356 opened.document.validate_shape()?;
357 let presets = write_equipment(root, &self.equipment)
358 .map_err(|message| ProjectError::Validation(vec![message]))?;
359 let mut document = opened.document.clone();
360 document.equipment_presets = presets;
361 document.validate_shape()?;
362 let candidate = OpenedProject {
363 manifest_path: opened.manifest_path.clone(),
364 document,
365 disk_issues: Vec::new(),
366 };
367 save_project(&candidate)?;
368 for item in &mut self.equipment {
369 if let Some(preset) = &mut item.preset {
370 preset.name = item.slug.clone();
371 }
372 }
373 self.opened = Some(candidate);
374 self.note_saved();
375 self.ok("Opgeslagen.");
376 Ok(())
377 }
378
379 pub fn project_root(&self) -> Option<PathBuf> {
380 self.opened
381 .as_ref()?
382 .manifest_path
383 .parent()
384 .map(PathBuf::from)
385 }
386
387 fn ok(&mut self, message: &str) {
388 self.notice = message.to_string();
389 self.notice_ok = true;
390 }
391
392 pub fn fail(&mut self, message: impl Into<String>) {
393 self.notice = message.into();
394 self.notice_ok = false;
395 }
396}
397
398impl Default for Session {
399 fn default() -> Self {
400 Self::new()
401 }
402}
403
404pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
405 let mut opened = create_project(root, "Nieuw project")?;
406 for (relative, bytes) in [
407 (MALE_ASSET, MALE_GLB),
408 (FEMALE_ASSET, FEMALE_GLB),
409 (
410 "assets/body/test-bodies-LICENSE.txt",
411 include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),
412 ),
413 ] {
414 let path = checked_project_path(root, relative)
415 .map_err(|error| ProjectError::Validation(vec![error]))?;
416 if !path.exists() {
417 write_bytes(&path, bytes)?;
418 }
419 }
420 opened.document.male_model = Some(MALE_ASSET.to_string());
421 opened.document.female_model = Some(FEMALE_ASSET.to_string());
422 save_project(&opened)?;
423 Ok(opened)
424}
425
426pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {
427 if !source.is_file() {
428 return Err("kies een bestand".to_string());
429 }
430 let name = source
431 .file_name()
432 .and_then(|name| name.to_str())
433 .filter(|name| {
434 !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')
435 })
436 .ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;
437 if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {
438 if let Ok(inside) = source_full.strip_prefix(&root_full) {
439 let relative = inside.to_string_lossy().replace('\\', "/");
440 portable_relative(&relative)?;
441 return Ok(relative);
442 }
443 }
444 let mut relative = format!("{folder}/{name}");
445 let stem = Path::new(name).file_stem().unwrap().to_string_lossy();
446 let ext = Path::new(name)
447 .extension()
448 .map(|x| format!(".{}", x.to_string_lossy()))
449 .unwrap_or_default();
450 let mut n = 2;
451 while root.join(&relative).exists() {
452 relative = format!("{folder}/{stem}-{n}{ext}");
453 n += 1;
454 }
455 portable_relative(&relative)?;
456 let dest = checked_project_path(root, &relative)?;
457 if let Some(parent) = dest.parent() {
458 fs::create_dir_all(parent).map_err(|err| err.to_string())?;
459 }
460 fs::copy(source, &dest).map_err(|err| err.to_string())?;
461 Ok(relative)
462}
463
464fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
465 portable_relative(relative)?;
466 let root_full = root.canonicalize().map_err(|e| e.to_string())?;
467 let path = root.join(relative);
468 let mut ancestor = path.as_path();
469 while std::fs::symlink_metadata(ancestor).is_err() {
470 ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;
471 }
472 let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;
473 if !canonical.starts_with(&root_full) {
474 return Err("het bestand ligt buiten de projectmap".into());
475 }
476 Ok(path)
477}
478
479fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
480 if let Some(parent) = path.parent() {
481 fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;
482 }
483 fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))
484}
485
486fn io_at(path: &Path, message: &str) -> ProjectError {
487 ProjectError::Io {
488 path: path.display().to_string(),
489 message: message.to_string(),
490 }
491}
492
493pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024;
494const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024;
495
496fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
497 use std::io::Read;
498 if !path.is_file() {
499 return Err("kies een regulier bestand".into());
500 }
501 let mut bytes = Vec::new();
502 fs::File::open(path)
503 .and_then(|file| file.take((limit + 1) as u64).read_to_end(&mut bytes))
504 .map_err(|e| e.to_string())?;
505 if bytes.len() > limit {
506 return Err("presetbestand overschrijdt het bestands-/projectbudget".into());
507 }
508 Ok(bytes)
509}
510fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
511 read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)
512}
513fn read_equipment_with_budget(
514 root: &Path,
515 presets: &[String],
516 mut budget: usize,
517) -> Vec<EquipmentItem> {
518 presets
519 .iter()
520 .map(|path| {
521 let failure = |error: String| EquipmentItem {
522 preset: None,
523 error: Some(error),
524 slug: Path::new(path)
525 .file_stem()
526 .unwrap_or_default()
527 .to_string_lossy()
528 .into(),
529 model: None,
530 legacy_path: Some(path.clone()),
531 };
532 let file = match checked_project_path(root, path) {
533 Ok(path) => path,
534 Err(error) => return failure(error),
535 };
536 let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe");
537 let limit = budget.min(if binary {
538 crate::equipment::MAX_FILE
539 } else {
540 MAX_LEGACY_PRESET_BYTES
541 });
542 let bytes = match read_limited(&file, limit) {
543 Ok(bytes) => bytes,
544 Err(error) => return failure(error),
545 };
546 budget -= bytes.len();
547 if binary {
548 return match Preset::decode(&bytes) {
549 Ok(preset) => EquipmentItem {
550 slug: preset.name.clone(),
551 model: preset.attachments.first().map(|a| a.label.clone()),
552 preset: Some(preset),
553 error: None,
554 legacy_path: None,
555 },
556 Err(error) => failure(error),
557 };
558 }
559 if let Ok(parsed) = serde_json::from_slice::<EquipmentFile>(&bytes) {
560 if valid_slug(&parsed.name) {
561 return EquipmentItem {
562 preset: None,
563 error: None,
564 slug: parsed.name,
565 model: parsed.model,
566 legacy_path: None,
567 };
568 }
569 }
570 failure("legacy preset; kies een OBJ om te configureren".into())
571 })
572 .collect()
573}
574
575fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
576 if items.len() > crate::project::MAX_PROJECT_PRESETS {
577 return Err("maximaal 64 equipmentpresets per project".into());
578 }
579 let mut budget = MAX_PROJECT_PRESET_BYTES;
580 let mut paths = Vec::new();
581 let mut names = std::collections::BTreeSet::new();
582 // Validate every record before writing. Config files are immutable snapshots:
583 // a failed manifest save leaves its previous equipment bytes intact.
584 for item in items {
585 if !names.insert(&item.slug) {
586 return Err("dubbele equipmentnaam".into());
587 }
588 if let Some(legacy) = item.legacy_path.as_ref().filter(|_| item.model.is_none()) {
589 let path = checked_project_path(root, legacy)?;
590 let length = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
591 budget = budget
592 .checked_sub(usize::try_from(length).map_err(|_| "preset te groot")?)
593 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
594 continue;
595 }
596 if !valid_slug(&item.slug) {
597 return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
598 }
599 if let Some(preset) = &item.preset {
600 let mut preset = preset.clone();
601 preset.name = item.slug.clone();
602 let length = preset.encode()?.len();
603 budget = budget
604 .checked_sub(length)
605 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
606 } else {
607 if let Some(model) = &item.model {
608 portable_relative(model)?;
609 }
610 let text = serde_json::to_vec_pretty(&EquipmentFile {
611 name: item.slug.clone(),
612 model: item.model.clone(),
613 })
614 .map_err(|e| e.to_string())?;
615 if text.len() > MAX_LEGACY_PRESET_BYTES {
616 return Err("legacy preset groter dan 64 KiB".into());
617 }
618 budget = budget
619 .checked_sub(text.len())
620 .ok_or("equipmentpresets samen groter dan 64 MiB")?;
621 }
622 }
623 for item in items {
624 if let Some(preset) = &item.preset {
625 let mut preset = preset.clone();
626 preset.name = item.slug.clone();
627 let bytes = preset.encode()?;
628 let relative = format!(
629 "presets/{}-{:08x}-{}.ppe",
630 item.slug,
631 crate::equipment::checksum(&bytes),
632 bytes.len()
633 );
634 let path = checked_project_path(root, &relative)?;
635 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
636 crate::equipment::write_snapshot(&path, &bytes)?;
637 paths.push(relative);
638 continue;
639 }
640 if item.model.is_none() {
641 if let Some(legacy) = &item.legacy_path {
642 paths.push(legacy.clone());
643 continue;
644 }
645 }
646 let file = EquipmentFile {
647 name: item.slug.clone(),
648 model: item.model.clone(),
649 };
650 let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
651 let mut number = 1;
652 loop {
653 let relative = format!("presets/{}-{number}.json", item.slug);
654 let path = checked_project_path(root, &relative)?;
655 if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {
656 if existing == text.as_bytes() {
657 paths.push(relative);
658 break;
659 }
660 } else if !path.exists() {
661 fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;
662 use std::io::Write;
663 let mut out = fs::OpenOptions::new()
664 .write(true)
665 .create_new(true)
666 .open(&path)
667 .map_err(|err| err.to_string())?;
668 out.write_all(text.as_bytes())
669 .map_err(|err| err.to_string())?;
670 out.sync_all().map_err(|err| err.to_string())?;
671 paths.push(relative);
672 break;
673 }
674 number += 1;
675 }
676 }
677 Ok(paths)
678}
679
680fn next_slug(items: &[EquipmentItem]) -> String {
681 let mut number = 1u32;
682 loop {
683 let slug = if number == 1 {
684 "nieuw".to_string()
685 } else {
686 format!("nieuw-{number}")
687 };
688 if !items.iter().any(|item| item.slug == slug) {
689 return slug;
690 }
691 number += 1;
692 }
693}
694
695fn valid_slug(slug: &str) -> bool {
696 crate::equipment::valid_name(slug)
697}
698
699pub fn manifest_in(root: &Path) -> PathBuf {
700 root.join(MANIFEST_NAME)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use std::time::{SystemTime, UNIX_EPOCH};
707
708 fn scratch() -> PathBuf {
709 let nanos = SystemTime::now()
710 .duration_since(UNIX_EPOCH)
711 .unwrap()
712 .as_nanos();
713 let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));
714 let _ = fs::remove_dir_all(&path);
715 path
716 }
717
718 #[test]
719 fn editing_budget_and_import_names_are_bounded_before_install() {
720 let mut session = Session::new();
721 let mut p = Preset::new("sword".into());
722 for i in 0..3 {
723 p.assets.push(crate::equipment::EmbeddedAsset {
724 path: format!("raw-{i}"),
725 bytes: vec![0; 8 * 1024 * 1024],
726 });
727 }
728 for _ in 0..2 {
729 let i = session.add_equipment();
730 session.equipment[i].preset = Some(p.clone());
731 }
732 assert!(session
733 .ensure_preset_budget(None, &p)
734 .unwrap_err()
735 .contains("64 MiB"));
736 assert!(session.ensure_preset_budget(Some(0), &p).is_ok());
737 assert_eq!(session.equipment.len(), 2);
738 let base = "a".repeat(64);
739 session.equipment[0].slug = base.clone();
740 let unique = session.available_equipment_name(&base).unwrap();
741 assert_eq!(unique.len(), 64);
742 assert_ne!(unique, base);
743 assert!(crate::equipment::valid_name(&unique));
744 }
745
746 #[test]
747 fn preset_read_budget_preserves_unloaded_references() {
748 let root = scratch();
749 fs::create_dir_all(root.join("presets")).unwrap();
750 let bytes = Preset::new("sword".into()).encode().unwrap();
751 for name in ["one", "two"] {
752 fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap();
753 }
754 let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()];
755 let items = read_equipment_with_budget(&root, &refs, bytes.len());
756 assert!(items[0].preset.is_some());
757 assert!(items[1].preset.is_none());
758 assert!(items[1].error.as_ref().unwrap().contains("budget"));
759 assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1]));
760 fs::write(
761 root.join("presets/old.json"),
762 vec![b' '; MAX_LEGACY_PRESET_BYTES + 1],
763 )
764 .unwrap();
765 assert!(read_equipment(&root, &["presets/old.json".into()])[0]
766 .error
767 .is_some());
768 fs::remove_dir_all(root).unwrap();
769 }
770 #[test]
771 fn too_many_presets_do_not_write_snapshots_or_replace_manifest() {
772 let root = scratch();
773 let mut session = Session::new();
774 session.install_new_project(&root).unwrap();
775 let before = fs::read(root.join(MANIFEST_NAME)).unwrap();
776 for _ in 0..=crate::project::MAX_PROJECT_PRESETS {
777 session.add_equipment();
778 }
779 assert!(session.save().unwrap_err().to_string().contains("64"));
780 assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before);
781 assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0);
782 assert!(session.is_dirty());
783 fs::remove_dir_all(root).unwrap();
784 }
785
786 #[test]
787 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
788 let root = scratch();
789 let mut session = Session::new();
790 session.install_new_project(&root).unwrap();
791 assert_eq!(session.screen, Screen::Bodies);
792 assert!(!session.is_dirty());
793 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
794 assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);
795 let outside = root
796 .parent()
797 .unwrap()
798 .join(format!("pd-src-{}", std::process::id()));
799 let _ = fs::remove_dir_all(&outside);
800 fs::create_dir_all(&outside).unwrap();
801 let source = outside.join("test-body-male.glb");
802 fs::write(&source, b"replacement-body").unwrap();
803 let relative = adopt_file(&root, &source, "assets/body").unwrap();
804 assert_ne!(relative, MALE_ASSET);
805 assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");
806 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
807 let extra = outside.join("extra.glb");
808 fs::write(&extra, b"extra-body").unwrap();
809 let relative = adopt_file(&root, &extra, "assets/body").unwrap();
810 session.set_body_path(Sex::Male, relative);
811 assert!(session.is_dirty());
812 session.save().unwrap();
813 assert!(!session.is_dirty());
814 assert_eq!(
815 fs::read(root.join("assets/body/extra.glb")).unwrap(),
816 b"extra-body"
817 );
818 let _ = fs::remove_dir_all(&outside);
819 let _ = fs::remove_dir_all(&root);
820 }
821
822 #[test]
823 fn navigation_and_equipment_crud_roundtrip() {
824 let root = scratch();
825 let mut session = Session::new();
826 session.show_body(Sex::Male);
827 assert_eq!(session.screen, Screen::Start);
828 session.install_new_project(&root).unwrap();
829 session.show_body(Sex::Female);
830 assert_eq!(session.screen, Screen::Body(Sex::Female));
831 session.show_equipment_list();
832 assert_eq!(session.screen, Screen::EquipmentList);
833 let index = session.add_equipment();
834 assert_eq!(index, 0);
835 session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());
836 session.save().unwrap();
837 let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();
838 assert!(text.contains("zwaard.glb"));
839 let mut again = Session::new();
840 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
841 again.install_loaded(opened);
842 assert_eq!(
843 again.equipment[0].model.as_deref(),
844 Some("assets/equipment/zwaard.glb")
845 );
846 again.delete_equipment(0);
847 again.save().unwrap();
848 assert!(again
849 .opened
850 .as_ref()
851 .unwrap()
852 .document
853 .equipment_presets
854 .is_empty());
855 // Old immutable record stays recoverable if an earlier save is restored.
856 assert!(root.join("presets/nieuw-1.json").exists());
857 let _ = fs::remove_dir_all(&root);
858 }
859
860 #[test]
861 fn failed_save_preserves_previous_equipment_and_manifest_bytes() {
862 let root = scratch();
863 let mut session = Session::new();
864 session.install_new_project(&root).unwrap();
865 session.add_equipment();
866 session.save().unwrap();
867 let manifest = fs::read(manifest_in(&root)).unwrap();
868 let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
869 let preset = fs::read(root.join(&relative)).unwrap();
870 session.equipment[0].slug = "changed".into();
871 // Missing body fails after immutable equipment snapshots are written.
872 session.set_body_path(Sex::Male, "assets/body/missing.glb".into());
873 assert!(session.save().is_err());
874 assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);
875 assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);
876 assert!(session.is_dirty());
877 let _ = fs::remove_dir_all(root);
878 }
879
880 #[test]
881 fn dirty_confirm_save_discard_and_cancel_keep_bytes() {
882 let root = scratch();
883 let mut session = Session::new();
884 session.install_new_project(&root).unwrap();
885 session.set_name("Gewijzigd".to_string());
886 assert!(session.is_dirty());
887 assert!(!session.gate(Intent::Quit));
888 assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);
889 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
890 assert!(!session.gate(Intent::PickLoad));
891 assert_eq!(
892 session.resolve(Choice::Discard),
893 AfterChoice::Go(Intent::PickLoad)
894 );
895 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
896 assert!(session.is_dirty()); // canceled file picker must not discard the old project
897 session.set_name("Bewaard".to_string());
898 assert!(!session.gate(Intent::PickNew));
899 assert_eq!(
900 session.resolve(Choice::Save),
901 AfterChoice::SaveThen(Intent::PickNew)
902 );
903 session.save().unwrap();
904 assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");
905 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
906 assert_eq!(opened.document.name, "Bewaard");
907 let _ = fs::remove_dir_all(&root);
908 }
909}