BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / session.rs

session.rs

session.rs 25.5 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)]
64struct EquipmentFile {
65 name: String,
66 model: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
70pub struct EquipmentItem {
71 pub slug: String,
72 pub model: Option<String>,
73 pub legacy_path: Option<String>,
74 pub preset: Option<Preset>,
75 pub error: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq)]
79pub struct Session {
80 pub screen: Screen,
81 pub active_body: Option<Sex>,
82 pub opened: Option<OpenedProject>,
83 pub saved_document: Option<ProjectFile>,
84 pub equipment: Vec<EquipmentItem>,
85 pub saved_equipment: Vec<EquipmentItem>,
86 pub confirm: Option<Intent>,
87 pub notice: String,
88 pub notice_ok: bool,
89}
90
91impl Session {
92 pub fn new() -> Self {
93 Self {
94 screen: Screen::Start,
95 active_body: None,
96 opened: None,
97 saved_document: None,
98 equipment: Vec::new(),
99 saved_equipment: Vec::new(),
100 confirm: None,
101 notice: "Laad een project of maak een nieuwe projectmap.".to_string(),
102 notice_ok: true,
103 }
104 }
105
106 pub fn shell_size(&self) -> [f32; 2] {
107 match self.screen {
108 Screen::Start => START_WINDOW,
109 _ => PROJECT_WINDOW,
110 }
111 }
112
113 pub fn is_dirty(&self) -> bool {
114 self.opened.as_ref().map(|opened| &opened.document) != self.saved_document.as_ref()
115 || self.equipment != self.saved_equipment
116 }
117
118 pub fn gate(&mut self, intent: Intent) -> bool {
119 if self.is_dirty() {
120 self.confirm = Some(intent);
121 false
122 } else {
123 true
124 }
125 }
126
127 pub fn resolve(&mut self, choice: Choice) -> AfterChoice {
128 let Some(intent) = self.confirm.take() else {
129 return AfterChoice::Stay;
130 };
131 match choice {
132 Choice::Cancel => {
133 self.confirm = None;
134 AfterChoice::Stay
135 }
136 Choice::Discard => AfterChoice::Go(intent),
137 Choice::Save => {
138 self.confirm = Some(intent);
139 AfterChoice::SaveThen(intent)
140 }
141 }
142 }
143
144 pub fn note_saved(&mut self) {
145 if let Some(opened) = &self.opened {
146 self.saved_document = Some(opened.document.clone());
147 }
148 self.saved_equipment = self.equipment.clone();
149 self.confirm = None;
150 }
151
152 pub fn show_bodies(&mut self) {
153 if self.opened.is_some() {
154 self.screen = Screen::Bodies;
155 }
156 }
157
158 pub fn show_body(&mut self, sex: Sex) {
159 if self.opened.is_some() {
160 self.active_body = Some(sex);
161 self.screen = Screen::Body(sex);
162 }
163 }
164
165 pub fn show_equipment_list(&mut self) {
166 if self.opened.is_some() {
167 self.screen = Screen::EquipmentList;
168 }
169 }
170
171 pub fn show_equipment(&mut self, index: usize) {
172 if index < self.equipment.len() {
173 self.screen = Screen::Equipment(index);
174 }
175 }
176
177 pub fn back(&mut self) {
178 self.screen = match self.screen {
179 Screen::Body(_) => Screen::Bodies,
180 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
181 Screen::Equipment(_) => Screen::EquipmentList,
182 other => other,
183 };
184 }
185
186 pub fn set_name(&mut self, name: String) {
187 if let Some(opened) = &mut self.opened {
188 opened.document.name = name;
189 }
190 }
191
192 pub fn set_render(&mut self, width: u32, height: u32, fps: u32) {
193 if let Some(opened) = &mut self.opened {
194 opened.document.render.target_width = width;
195 opened.document.render.target_height = height;
196 opened.document.render.fps = fps;
197 }
198 }
199
200 pub fn body_path(&self, sex: Sex) -> Option<&str> {
201 let document = &self.opened.as_ref()?.document;
202 match sex {
203 Sex::Male => document.male_model.as_deref(),
204 Sex::Female => document.female_model.as_deref(),
205 }
206 }
207
208 pub fn set_body_path(&mut self, sex: Sex, relative: String) {
209 let Some(opened) = &mut self.opened else {
210 return;
211 };
212 let slot = match sex {
213 Sex::Male => &mut opened.document.male_model,
214 Sex::Female => &mut opened.document.female_model,
215 };
216 *slot = Some(relative);
217 }
218
219 pub fn add_equipment(&mut self) -> usize {
220 let slug = next_slug(&self.equipment);
221 self.equipment.push(EquipmentItem {
222 preset: Some(Preset::new(slug.clone())),
223 error: None,
224 slug,
225 model: None,
226 legacy_path: None,
227 });
228 let index = self.equipment.len() - 1;
229 self.screen = Screen::Equipment(index);
230 index
231 }
232
233 pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
234 let slug = slug.trim().to_string();
235 if !valid_slug(&slug) {
236 return Err(
237 "de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'"
238 .to_string(),
239 );
240 }
241 if self
242 .equipment
243 .iter()
244 .enumerate()
245 .any(|(other, item)| other != index && item.slug == slug)
246 {
247 return Err("die equipmentnaam bestaat al".to_string());
248 }
249 if let Some(item) = self.equipment.get_mut(index) {
250 item.slug = slug;
251 item.legacy_path = None;
252 }
253 Ok(())
254 }
255
256 pub fn set_equipment_model(&mut self, index: usize, relative: String) {
257 if let Some(item) = self.equipment.get_mut(index) {
258 item.model = Some(relative);
259 item.legacy_path = None;
260 item.preset = None;
261 item.error = None;
262 }
263 }
264
265 pub fn delete_equipment(&mut self, index: usize) {
266 if index < self.equipment.len() {
267 self.equipment.remove(index);
268 self.screen = Screen::EquipmentList;
269 }
270 }
271
272 pub fn install_new_project(&mut self, root: &Path) -> Result<(), ProjectError> {
273 let opened = create_seeded_project(root)?;
274 self.install_opened(opened);
275 self.ok("Projectmap aangemaakt, met de testlichamen in assets/body.");
276 Ok(())
277 }
278
279 pub fn install_loaded(&mut self, opened: OpenedProject) {
280 let root = opened
281 .manifest_path
282 .parent()
283 .map(Path::to_path_buf)
284 .unwrap_or_default();
285 let equipment = read_equipment(&root, &opened.document.equipment_presets);
286 self.equipment = equipment.clone();
287 self.saved_equipment = equipment;
288 self.saved_document = Some(opened.document.clone());
289 self.opened = Some(opened);
290 self.screen = Screen::Bodies;
291 self.active_body = None;
292 self.confirm = None;
293 self.ok("Project geopend.");
294 }
295
296 fn install_opened(&mut self, opened: OpenedProject) {
297 self.equipment.clear();
298 self.saved_equipment.clear();
299 self.saved_document = Some(opened.document.clone());
300 self.opened = Some(opened);
301 self.screen = Screen::Bodies;
302 self.active_body = None;
303 self.confirm = None;
304 }
305
306 pub fn save(&mut self) -> Result<(), ProjectError> {
307 let Some(opened) = self.opened.clone() else {
308 return Err(ProjectError::Validation(vec![
309 "er is geen project om op te slaan".to_string(),
310 ]));
311 };
312 let root = opened
313 .manifest_path
314 .parent()
315 .ok_or_else(|| ProjectError::Io {
316 path: opened.manifest_path.display().to_string(),
317 message: "het .ppd-bestand heeft geen projectmap".to_string(),
318 })?;
319 opened.document.validate_shape()?;
320 let presets = write_equipment(root, &self.equipment)
321 .map_err(|message| ProjectError::Validation(vec![message]))?;
322 let mut document = opened.document.clone();
323 document.equipment_presets = presets;
324 document.validate_shape()?;
325 let candidate = OpenedProject {
326 manifest_path: opened.manifest_path.clone(),
327 document,
328 disk_issues: Vec::new(),
329 };
330 save_project(&candidate)?;
331 for item in &mut self.equipment {
332 if let Some(preset) = &mut item.preset {
333 preset.name = item.slug.clone();
334 }
335 }
336 self.opened = Some(candidate);
337 self.note_saved();
338 self.ok("Opgeslagen.");
339 Ok(())
340 }
341
342 pub fn project_root(&self) -> Option<PathBuf> {
343 self.opened
344 .as_ref()?
345 .manifest_path
346 .parent()
347 .map(PathBuf::from)
348 }
349
350 fn ok(&mut self, message: &str) {
351 self.notice = message.to_string();
352 self.notice_ok = true;
353 }
354
355 pub fn fail(&mut self, message: impl Into<String>) {
356 self.notice = message.into();
357 self.notice_ok = false;
358 }
359}
360
361impl Default for Session {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
368 let mut opened = create_project(root, "Nieuw project")?;
369 for (relative, bytes) in [
370 (MALE_ASSET, MALE_GLB),
371 (FEMALE_ASSET, FEMALE_GLB),
372 (
373 "assets/body/test-bodies-LICENSE.txt",
374 include_bytes!("../tests/fixtures/bodies/LICENSE").as_slice(),
375 ),
376 ] {
377 let path = checked_project_path(root, relative)
378 .map_err(|error| ProjectError::Validation(vec![error]))?;
379 if !path.exists() {
380 write_bytes(&path, bytes)?;
381 }
382 }
383 opened.document.male_model = Some(MALE_ASSET.to_string());
384 opened.document.female_model = Some(FEMALE_ASSET.to_string());
385 save_project(&opened)?;
386 Ok(opened)
387}
388
389pub fn adopt_file(root: &Path, source: &Path, folder: &str) -> Result<String, String> {
390 if !source.is_file() {
391 return Err("kies een bestand".to_string());
392 }
393 let name = source
394 .file_name()
395 .and_then(|name| name.to_str())
396 .filter(|name| {
397 !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains('\0')
398 })
399 .ok_or_else(|| "de bestandsnaam is niet bruikbaar".to_string())?;
400 if let (Ok(source_full), Ok(root_full)) = (source.canonicalize(), root.canonicalize()) {
401 if let Ok(inside) = source_full.strip_prefix(&root_full) {
402 let relative = inside.to_string_lossy().replace('\\', "/");
403 portable_relative(&relative)?;
404 return Ok(relative);
405 }
406 }
407 let mut relative = format!("{folder}/{name}");
408 let stem = Path::new(name).file_stem().unwrap().to_string_lossy();
409 let ext = Path::new(name)
410 .extension()
411 .map(|x| format!(".{}", x.to_string_lossy()))
412 .unwrap_or_default();
413 let mut n = 2;
414 while root.join(&relative).exists() {
415 relative = format!("{folder}/{stem}-{n}{ext}");
416 n += 1;
417 }
418 portable_relative(&relative)?;
419 let dest = checked_project_path(root, &relative)?;
420 if let Some(parent) = dest.parent() {
421 fs::create_dir_all(parent).map_err(|err| err.to_string())?;
422 }
423 fs::copy(source, &dest).map_err(|err| err.to_string())?;
424 Ok(relative)
425}
426
427fn checked_project_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
428 portable_relative(relative)?;
429 let root_full = root.canonicalize().map_err(|e| e.to_string())?;
430 let path = root.join(relative);
431 let mut ancestor = path.as_path();
432 while std::fs::symlink_metadata(ancestor).is_err() {
433 ancestor = ancestor.parent().ok_or("ongeldig projectpad")?;
434 }
435 let canonical = ancestor.canonicalize().map_err(|e| e.to_string())?;
436 if !canonical.starts_with(&root_full) {
437 return Err("het bestand ligt buiten de projectmap".into());
438 }
439 Ok(path)
440}
441
442fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
443 if let Some(parent) = path.parent() {
444 fs::create_dir_all(parent).map_err(|err| io_at(path, &err.to_string()))?;
445 }
446 fs::write(path, bytes).map_err(|err| io_at(path, &err.to_string()))
447}
448
449fn io_at(path: &Path, message: &str) -> ProjectError {
450 ProjectError::Io {
451 path: path.display().to_string(),
452 message: message.to_string(),
453 }
454}
455
456fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
457 presets
458 .iter()
459 .map(|path| {
460 let file = match checked_project_path(root, path) {
461 Ok(path) => path,
462 Err(_) => {
463 return EquipmentItem {
464 preset: None,
465 error: Some("onveilig presetpad".into()),
466 slug: "onveilig".into(),
467 model: None,
468 legacy_path: Some(path.clone()),
469 }
470 }
471 };
472 if file.extension().and_then(|e| e.to_str()) == Some("ppe") {
473 return match Preset::read(&file) {
474 Ok(preset) => EquipmentItem {
475 slug: preset.name.clone(),
476 model: preset.attachments.first().map(|a| a.label.clone()),
477 preset: Some(preset),
478 error: None,
479 legacy_path: None,
480 },
481 Err(error) => EquipmentItem {
482 slug: file
483 .file_stem()
484 .unwrap_or_default()
485 .to_string_lossy()
486 .into(),
487 model: None,
488 preset: None,
489 error: Some(error),
490 legacy_path: Some(path.clone()),
491 },
492 };
493 }
494 if let Ok(text) = fs::read_to_string(&file) {
495 if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
496 if valid_slug(&parsed.name) {
497 return EquipmentItem {
498 preset: None,
499 error: None,
500 slug: parsed.name,
501 model: parsed.model,
502 legacy_path: None,
503 };
504 }
505 }
506 }
507 let slug = path
508 .rsplit('/')
509 .next()
510 .unwrap_or(path)
511 .split('.')
512 .next()
513 .unwrap_or("item")
514 .to_string();
515 EquipmentItem {
516 preset: None,
517 error: Some("legacy preset; kies een OBJ om te configureren".into()),
518 slug,
519 model: None,
520 legacy_path: Some(path.clone()),
521 }
522 })
523 .collect()
524}
525
526fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
527 let mut paths = Vec::new();
528 let mut names = std::collections::BTreeSet::new();
529 // Validate every record before writing. Config files are immutable snapshots:
530 // a failed manifest save leaves its previous equipment bytes intact.
531 for item in items {
532 if !names.insert(&item.slug) {
533 return Err("dubbele equipmentnaam".into());
534 }
535 if item.legacy_path.is_some() && item.model.is_none() {
536 continue;
537 }
538 if !valid_slug(&item.slug) {
539 return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
540 }
541 if let Some(preset) = &item.preset {
542 let mut preset = preset.clone();
543 preset.name = item.slug.clone();
544 preset.validate()?;
545 } else if let Some(model) = &item.model {
546 portable_relative(model)?;
547 }
548 }
549 for item in items {
550 if let Some(preset) = &item.preset {
551 let mut preset = preset.clone();
552 preset.name = item.slug.clone();
553 let bytes = preset.encode()?;
554 let relative = format!(
555 "presets/{}-{:08x}-{}.ppe",
556 item.slug,
557 crate::equipment::checksum(&bytes),
558 bytes.len()
559 );
560 let path = checked_project_path(root, &relative)?;
561 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
562 crate::equipment::write_snapshot(&path, &bytes)?;
563 paths.push(relative);
564 continue;
565 }
566 if item.model.is_none() {
567 if let Some(legacy) = &item.legacy_path {
568 paths.push(legacy.clone());
569 continue;
570 }
571 }
572 let file = EquipmentFile {
573 name: item.slug.clone(),
574 model: item.model.clone(),
575 };
576 let text = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
577 let mut number = 1;
578 loop {
579 let relative = format!("presets/{}-{number}.json", item.slug);
580 let path = checked_project_path(root, &relative)?;
581 if let Ok(existing) = fs::read_to_string(&path) {
582 if existing == text {
583 paths.push(relative);
584 break;
585 }
586 } else if !path.exists() {
587 fs::create_dir_all(path.parent().unwrap()).map_err(|err| err.to_string())?;
588 use std::io::Write;
589 let mut out = fs::OpenOptions::new()
590 .write(true)
591 .create_new(true)
592 .open(&path)
593 .map_err(|err| err.to_string())?;
594 out.write_all(text.as_bytes())
595 .map_err(|err| err.to_string())?;
596 out.sync_all().map_err(|err| err.to_string())?;
597 paths.push(relative);
598 break;
599 }
600 number += 1;
601 }
602 }
603 Ok(paths)
604}
605
606fn next_slug(items: &[EquipmentItem]) -> String {
607 let mut number = 1u32;
608 loop {
609 let slug = if number == 1 {
610 "nieuw".to_string()
611 } else {
612 format!("nieuw-{number}")
613 };
614 if !items.iter().any(|item| item.slug == slug) {
615 return slug;
616 }
617 number += 1;
618 }
619}
620
621fn valid_slug(slug: &str) -> bool {
622 !slug.is_empty()
623 && slug != "body"
624 && slug
625 .chars()
626 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
627}
628
629pub fn manifest_in(root: &Path) -> PathBuf {
630 root.join(MANIFEST_NAME)
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use std::time::{SystemTime, UNIX_EPOCH};
637
638 fn scratch() -> PathBuf {
639 let nanos = SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .unwrap()
642 .as_nanos();
643 let path = std::env::temp_dir().join(format!("paperdoll-session-{nanos}"));
644 let _ = fs::remove_dir_all(&path);
645 path
646 }
647
648 #[test]
649 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
650 let root = scratch();
651 let mut session = Session::new();
652 session.install_new_project(&root).unwrap();
653 assert_eq!(session.screen, Screen::Bodies);
654 assert!(!session.is_dirty());
655 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
656 assert_eq!(fs::read(root.join(FEMALE_ASSET)).unwrap(), FEMALE_GLB);
657 let outside = root
658 .parent()
659 .unwrap()
660 .join(format!("pd-src-{}", std::process::id()));
661 let _ = fs::remove_dir_all(&outside);
662 fs::create_dir_all(&outside).unwrap();
663 let source = outside.join("test-body-male.glb");
664 fs::write(&source, b"replacement-body").unwrap();
665 let relative = adopt_file(&root, &source, "assets/body").unwrap();
666 assert_ne!(relative, MALE_ASSET);
667 assert_eq!(fs::read(root.join(&relative)).unwrap(), b"replacement-body");
668 assert_eq!(fs::read(root.join(MALE_ASSET)).unwrap(), MALE_GLB);
669 let extra = outside.join("extra.glb");
670 fs::write(&extra, b"extra-body").unwrap();
671 let relative = adopt_file(&root, &extra, "assets/body").unwrap();
672 session.set_body_path(Sex::Male, relative);
673 assert!(session.is_dirty());
674 session.save().unwrap();
675 assert!(!session.is_dirty());
676 assert_eq!(
677 fs::read(root.join("assets/body/extra.glb")).unwrap(),
678 b"extra-body"
679 );
680 let _ = fs::remove_dir_all(&outside);
681 let _ = fs::remove_dir_all(&root);
682 }
683
684 #[test]
685 fn navigation_and_equipment_crud_roundtrip() {
686 let root = scratch();
687 let mut session = Session::new();
688 session.show_body(Sex::Male);
689 assert_eq!(session.screen, Screen::Start);
690 session.install_new_project(&root).unwrap();
691 session.show_body(Sex::Female);
692 assert_eq!(session.screen, Screen::Body(Sex::Female));
693 session.show_equipment_list();
694 assert_eq!(session.screen, Screen::EquipmentList);
695 let index = session.add_equipment();
696 assert_eq!(index, 0);
697 session.set_equipment_model(0, "assets/equipment/zwaard.glb".to_string());
698 session.save().unwrap();
699 let text = fs::read_to_string(root.join("presets/nieuw-1.json")).unwrap();
700 assert!(text.contains("zwaard.glb"));
701 let mut again = Session::new();
702 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
703 again.install_loaded(opened);
704 assert_eq!(
705 again.equipment[0].model.as_deref(),
706 Some("assets/equipment/zwaard.glb")
707 );
708 again.delete_equipment(0);
709 again.save().unwrap();
710 assert!(again
711 .opened
712 .as_ref()
713 .unwrap()
714 .document
715 .equipment_presets
716 .is_empty());
717 // Old immutable record stays recoverable if an earlier save is restored.
718 assert!(root.join("presets/nieuw-1.json").exists());
719 let _ = fs::remove_dir_all(&root);
720 }
721
722 #[test]
723 fn failed_save_preserves_previous_equipment_and_manifest_bytes() {
724 let root = scratch();
725 let mut session = Session::new();
726 session.install_new_project(&root).unwrap();
727 session.add_equipment();
728 session.save().unwrap();
729 let manifest = fs::read(manifest_in(&root)).unwrap();
730 let relative = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
731 let preset = fs::read(root.join(&relative)).unwrap();
732 session.equipment[0].slug = "changed".into();
733 // Missing body fails after immutable equipment snapshots are written.
734 session.set_body_path(Sex::Male, "assets/body/missing.glb".into());
735 assert!(session.save().is_err());
736 assert_eq!(fs::read(manifest_in(&root)).unwrap(), manifest);
737 assert_eq!(fs::read(root.join(&relative)).unwrap(), preset);
738 assert!(session.is_dirty());
739 let _ = fs::remove_dir_all(root);
740 }
741
742 #[test]
743 fn dirty_confirm_save_discard_and_cancel_keep_bytes() {
744 let root = scratch();
745 let mut session = Session::new();
746 session.install_new_project(&root).unwrap();
747 session.set_name("Gewijzigd".to_string());
748 assert!(session.is_dirty());
749 assert!(!session.gate(Intent::Quit));
750 assert_eq!(session.resolve(Choice::Cancel), AfterChoice::Stay);
751 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
752 assert!(!session.gate(Intent::PickLoad));
753 assert_eq!(
754 session.resolve(Choice::Discard),
755 AfterChoice::Go(Intent::PickLoad)
756 );
757 assert_eq!(session.opened.as_ref().unwrap().document.name, "Gewijzigd");
758 assert!(session.is_dirty()); // canceled file picker must not discard the old project
759 session.set_name("Bewaard".to_string());
760 assert!(!session.gate(Intent::PickNew));
761 assert_eq!(
762 session.resolve(Choice::Save),
763 AfterChoice::SaveThen(Intent::PickNew)
764 );
765 session.save().unwrap();
766 assert_eq!(session.opened.as_ref().unwrap().document.name, "Bewaard");
767 let opened = crate::project::open_project(&manifest_in(&root)).unwrap();
768 assert_eq!(opened.document.name, "Bewaard");
769 let _ = fs::remove_dir_all(&root);
770 }
771}