• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Ortham / libloadorder / 9725749600

29 Jun 2024 05:19PM UTC coverage: 91.286% (-0.5%) from 91.749%
9725749600

push

github

Ortham
Fix some plugins being hoisted too late

If there were multiple hoisted plugins then some could be moved into the wrong positions.

92 of 92 new or added lines in 4 files covered. (100.0%)

46 existing lines in 5 files now uncovered.

7207 of 7895 relevant lines covered (91.29%)

113996.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.99
/src/load_order/timestamp_based.rs
1
/*
2
 * This file is part of libloadorder
3
 *
4
 * Copyright (C) 2017 Oliver Hamlet
5
 *
6
 * libloadorder is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * libloadorder is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
 * GNU General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU General Public License
17
 * along with libloadorder. If not, see <http://www.gnu.org/licenses/>.
18
 */
19
use std::cmp::Ordering;
20
use std::fs::File;
21
use std::io::{BufRead, BufReader, BufWriter, Write};
22
use std::time::{Duration, SystemTime, UNIX_EPOCH};
23

24
use rayon::prelude::*;
25
use regex::Regex;
26

27
use super::mutable::{hoist_masters, load_active_plugins, MutableLoadOrder};
28
use super::readable::{ReadableLoadOrder, ReadableLoadOrderBase};
29
use super::strict_encode;
30
use super::writable::{
31
    activate, add, create_parent_dirs, deactivate, remove, set_active_plugins, WritableLoadOrder,
32
};
33
use crate::enums::{Error, GameId};
34
use crate::game_settings::GameSettings;
35
use crate::plugin::Plugin;
36

37
const GAME_FILES_HEADER: &[u8] = b"[Game Files]";
38

39
#[derive(Clone, Debug)]
40
pub struct TimestampBasedLoadOrder {
41
    game_settings: GameSettings,
42
    plugins: Vec<Plugin>,
43
}
44

45
impl TimestampBasedLoadOrder {
46
    pub fn new(game_settings: GameSettings) -> Self {
2✔
47
        Self {
2✔
48
            game_settings,
2✔
49
            plugins: Vec::new(),
2✔
50
        }
2✔
51
    }
2✔
52

53
    fn load_plugins_from_dir(&self) -> Vec<Plugin> {
17✔
54
        let filenames = self.find_plugins();
17✔
55
        let game_settings = self.game_settings();
17✔
56

17✔
57
        filenames
17✔
58
            .par_iter()
17✔
59
            .filter_map(|f| Plugin::new(f, game_settings).ok())
91✔
60
            .collect()
17✔
61
    }
17✔
62

63
    fn save_active_plugins(&mut self) -> Result<(), Error> {
6✔
64
        let path = self.game_settings().active_plugins_file();
6✔
65
        create_parent_dirs(path)?;
6✔
66

67
        let prelude = get_file_prelude(self.game_settings())?;
6✔
68

69
        let file = File::create(path).map_err(|e| Error::IoError(path.clone(), e))?;
6✔
70
        let mut writer = BufWriter::new(file);
6✔
71
        writer
6✔
72
            .write_all(&prelude)
6✔
73
            .map_err(|e| Error::IoError(path.clone(), e))?;
6✔
74
        for (index, plugin_name) in self.active_plugin_names().iter().enumerate() {
6✔
75
            if self.game_settings().id() == GameId::Morrowind {
5✔
76
                write!(writer, "GameFile{}=", index)
1✔
77
                    .map_err(|e| Error::IoError(path.clone(), e))?;
1✔
78
            }
4✔
79
            writer
5✔
80
                .write_all(&strict_encode(plugin_name)?)
5✔
81
                .map_err(|e| Error::IoError(path.clone(), e))?;
4✔
82
            writeln!(writer).map_err(|e| Error::IoError(path.clone(), e))?;
4✔
83
        }
84

85
        Ok(())
5✔
86
    }
6✔
87
}
88

89
impl ReadableLoadOrderBase for TimestampBasedLoadOrder {
90
    fn game_settings_base(&self) -> &GameSettings {
166✔
91
        &self.game_settings
166✔
92
    }
166✔
93

94
    fn plugins(&self) -> &[Plugin] {
49✔
95
        &self.plugins
49✔
96
    }
49✔
97
}
98

99
impl MutableLoadOrder for TimestampBasedLoadOrder {
100
    fn plugins_mut(&mut self) -> &mut Vec<Plugin> {
60✔
101
        &mut self.plugins
60✔
102
    }
60✔
103
}
104

105
impl WritableLoadOrder for TimestampBasedLoadOrder {
106
    fn game_settings_mut(&mut self) -> &mut GameSettings {
1✔
107
        &mut self.game_settings
1✔
108
    }
1✔
109

110
    fn load(&mut self) -> Result<(), Error> {
17✔
111
        self.plugins_mut().clear();
17✔
112

17✔
113
        self.plugins = self.load_plugins_from_dir();
17✔
114
        self.plugins.par_sort_by(plugin_sorter);
17✔
115

17✔
116
        let regex = Regex::new(r"(?i)GameFile[0-9]{1,3}=(.+\.es(?:m|p))")
17✔
117
            .expect("Hardcoded GameFile ini entry regex should be valid");
17✔
118
        let game_id = self.game_settings().id();
17✔
119
        let line_mapper = |line: &str| plugin_line_mapper(line, &regex, game_id);
20✔
120

121
        load_active_plugins(self, line_mapper)?;
17✔
122

123
        self.add_implicitly_active_plugins()?;
17✔
124

125
        hoist_masters(&mut self.plugins)?;
17✔
126

127
        Ok(())
17✔
128
    }
17✔
129

130
    fn save(&mut self) -> Result<(), Error> {
6✔
131
        save_load_order_using_timestamps(self)?;
6✔
132

133
        self.save_active_plugins()
6✔
134
    }
6✔
135

136
    fn add(&mut self, plugin_name: &str) -> Result<usize, Error> {
×
137
        add(self, plugin_name)
×
138
    }
×
139

140
    fn remove(&mut self, plugin_name: &str) -> Result<(), Error> {
×
141
        remove(self, plugin_name)
×
142
    }
×
143

UNCOV
144
    fn set_load_order(&mut self, plugin_names: &[&str]) -> Result<(), Error> {
×
UNCOV
145
        self.replace_plugins(plugin_names)
×
UNCOV
146
    }
×
147

UNCOV
148
    fn set_plugin_index(&mut self, plugin_name: &str, position: usize) -> Result<usize, Error> {
×
UNCOV
149
        MutableLoadOrder::set_plugin_index(self, plugin_name, position)
×
UNCOV
150
    }
×
151

152
    fn is_self_consistent(&self) -> Result<bool, Error> {
3✔
153
        Ok(true)
3✔
154
    }
3✔
155

156
    /// A timestamp-based load order is never ambiguous, as even if two or more plugins share the
157
    /// same timestamp, they load in descending filename order.
158
    fn is_ambiguous(&self) -> Result<bool, Error> {
2✔
159
        Ok(false)
2✔
160
    }
2✔
161

162
    fn activate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
163
        activate(self, plugin_name)
×
164
    }
×
165

166
    fn deactivate(&mut self, plugin_name: &str) -> Result<(), Error> {
×
167
        deactivate(self, plugin_name)
×
168
    }
×
169

170
    fn set_active_plugins(&mut self, active_plugin_names: &[&str]) -> Result<(), Error> {
×
171
        set_active_plugins(self, active_plugin_names)
×
172
    }
×
173
}
174

175
pub fn save_load_order_using_timestamps<T: MutableLoadOrder>(
7✔
176
    load_order: &mut T,
7✔
177
) -> Result<(), Error> {
7✔
178
    let timestamps = padded_unique_timestamps(load_order.plugins());
7✔
179

7✔
180
    load_order
7✔
181
        .plugins_mut()
7✔
182
        .par_iter_mut()
7✔
183
        .zip(timestamps.into_par_iter())
7✔
184
        .map(|(ref mut plugin, timestamp)| plugin.set_modification_time(timestamp))
28✔
185
        .collect::<Result<Vec<_>, Error>>()
7✔
186
        .map(|_| ())
7✔
187
}
7✔
188

189
fn plugin_sorter(a: &Plugin, b: &Plugin) -> Ordering {
91✔
190
    if a.is_master_file() == b.is_master_file() {
91✔
191
        match a.modification_time().cmp(&b.modification_time()) {
60✔
192
            Ordering::Equal => a.name().cmp(b.name()).reverse(),
2✔
193
            x => x,
58✔
194
        }
195
    } else if a.is_master_file() {
31✔
196
        Ordering::Less
17✔
197
    } else {
198
        Ordering::Greater
14✔
199
    }
200
}
91✔
201

202
fn plugin_line_mapper(mut line: &str, regex: &Regex, game_id: GameId) -> Option<String> {
20✔
203
    if game_id == GameId::Morrowind {
20✔
204
        line = regex
7✔
205
            .captures(line)
7✔
206
            .and_then(|c| c.get(1))
7✔
207
            .map_or("", |m| m.as_str());
7✔
208
    }
13✔
209

210
    if line.is_empty() || line.starts_with('#') {
20✔
211
        None
5✔
212
    } else {
213
        Some(line.to_owned())
15✔
214
    }
215
}
20✔
216

217
fn padded_unique_timestamps(plugins: &[Plugin]) -> Vec<SystemTime> {
7✔
218
    let mut timestamps: Vec<SystemTime> = plugins.iter().map(Plugin::modification_time).collect();
7✔
219

7✔
220
    timestamps.sort();
7✔
221
    timestamps.dedup();
7✔
222

223
    while timestamps.len() < plugins.len() {
8✔
224
        let timestamp = *timestamps.last().unwrap_or(&UNIX_EPOCH) + Duration::from_secs(60);
1✔
225
        timestamps.push(timestamp);
1✔
226
    }
1✔
227

228
    timestamps
7✔
229
}
7✔
230

231
fn get_file_prelude(game_settings: &GameSettings) -> Result<Vec<u8>, Error> {
6✔
232
    let mut prelude: Vec<u8> = Vec::new();
6✔
233

6✔
234
    let path = game_settings.active_plugins_file();
6✔
235

6✔
236
    if game_settings.id() == GameId::Morrowind && path.exists() {
6✔
237
        let input = File::open(path).map_err(|e| Error::IoError(path.clone(), e))?;
1✔
238
        let buffered = BufReader::new(input);
1✔
239

240
        for line in buffered.split(b'\n') {
2✔
241
            let line = line.map_err(|e| Error::IoError(path.clone(), e))?;
2✔
242
            prelude.append(&mut line.clone());
2✔
243
            prelude.push(b'\n');
2✔
244

2✔
245
            if line.starts_with(GAME_FILES_HEADER) {
2✔
246
                break;
1✔
247
            }
1✔
248
        }
249
    }
5✔
250

251
    Ok(prelude)
6✔
252
}
6✔
253

254
#[cfg(test)]
255
mod tests {
256
    use super::*;
257

258
    use crate::enums::GameId;
259
    use crate::load_order::tests::*;
260
    use crate::tests::copy_to_test_dir;
261
    use std::convert::TryInto;
262
    use std::fs::{remove_dir_all, File};
263
    use std::io::{Read, Write};
264
    use std::path::Path;
265
    use tempfile::tempdir;
266

267
    fn prepare(game_id: GameId, game_dir: &Path) -> TimestampBasedLoadOrder {
22✔
268
        let (game_settings, plugins) = mock_game_files(game_id, game_dir);
22✔
269
        TimestampBasedLoadOrder {
22✔
270
            game_settings,
22✔
271
            plugins,
22✔
272
        }
22✔
273
    }
22✔
274

275
    fn write_file(path: &Path) {
2✔
276
        let mut file = File::create(&path).unwrap();
2✔
277
        writeln!(file).unwrap();
2✔
278
    }
2✔
279

280
    #[test]
281
    fn load_should_reload_existing_plugins() {
1✔
282
        let tmp_dir = tempdir().unwrap();
1✔
283
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
284

1✔
285
        assert!(!load_order.plugins()[1].is_master_file());
1✔
286
        copy_to_test_dir("Blank.esm", "Blank.esp", &load_order.game_settings());
1✔
287
        let plugin_path = load_order
1✔
288
            .game_settings()
1✔
289
            .plugins_directory()
1✔
290
            .join("Blank.esp");
1✔
291
        set_file_timestamps(&plugin_path, 0);
1✔
292

1✔
293
        load_order.load().unwrap();
1✔
294

1✔
295
        assert!(load_order.plugins()[1].is_master_file());
1✔
296
    }
1✔
297

298
    #[test]
299
    fn load_should_remove_plugins_that_fail_to_load() {
1✔
300
        let tmp_dir = tempdir().unwrap();
1✔
301
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
302

1✔
303
        assert!(load_order.index_of("Blank.esp").is_some());
1✔
304
        assert!(load_order.index_of("Blank - Different.esp").is_some());
1✔
305

306
        let plugin_path = load_order
1✔
307
            .game_settings()
1✔
308
            .plugins_directory()
1✔
309
            .join("Blank.esp");
1✔
310
        write_file(&plugin_path);
1✔
311
        set_file_timestamps(&plugin_path, 0);
1✔
312

1✔
313
        let plugin_path = load_order
1✔
314
            .game_settings()
1✔
315
            .plugins_directory()
1✔
316
            .join("Blank - Different.esp");
1✔
317
        write_file(&plugin_path);
1✔
318
        set_file_timestamps(&plugin_path, 0);
1✔
319

1✔
320
        load_order.load().unwrap();
1✔
321
        assert!(load_order.index_of("Blank.esp").is_none());
1✔
322
        assert!(load_order.index_of("Blank - Different.esp").is_none());
1✔
323
    }
1✔
324

325
    #[test]
326
    fn load_should_sort_installed_plugins_into_their_timestamp_order_with_master_files_first() {
1✔
327
        let tmp_dir = tempdir().unwrap();
1✔
328
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
329

1✔
330
        set_timestamps(
1✔
331
            &load_order.game_settings().plugins_directory(),
1✔
332
            &[
1✔
333
                "Blank - Master Dependent.esp",
1✔
334
                "Blank.esm",
1✔
335
                "Blank - Different.esp",
1✔
336
                "Blank.esp",
1✔
337
                load_order.game_settings().master_file(),
1✔
338
            ],
1✔
339
        );
1✔
340

1✔
341
        load_order.load().unwrap();
1✔
342

1✔
343
        let expected_filenames = vec![
1✔
344
            "Blank.esm",
1✔
345
            load_order.game_settings().master_file(),
1✔
346
            "Blank - Master Dependent.esp",
1✔
347
            "Blank - Different.esp",
1✔
348
            "Blank.esp",
1✔
349
            "Blàñk.esp",
1✔
350
        ];
1✔
351

1✔
352
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
353
    }
1✔
354

355
    #[test]
356
    fn load_should_hoist_masters_that_masters_depend_on_to_load_before_their_dependents() {
1✔
357
        let tmp_dir = tempdir().unwrap();
1✔
358
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
359

1✔
360
        let master_dependent_master = "Blank - Master Dependent.esm";
1✔
361
        copy_to_test_dir(
1✔
362
            master_dependent_master,
1✔
363
            master_dependent_master,
1✔
364
            load_order.game_settings(),
1✔
365
        );
1✔
366

1✔
367
        let filenames = vec![
1✔
368
            "Blank - Master Dependent.esm",
1✔
369
            "Blank - Master Dependent.esp",
1✔
370
            "Blank.esm",
1✔
371
            "Blank - Different.esp",
1✔
372
            "Blàñk.esp",
1✔
373
            "Blank.esp",
1✔
374
            "Oblivion.esm",
1✔
375
        ];
1✔
376
        set_timestamps(&load_order.game_settings().plugins_directory(), &filenames);
1✔
377

1✔
378
        load_order.load().unwrap();
1✔
379

1✔
380
        let expected_filenames = vec![
1✔
381
            "Blank.esm",
1✔
382
            "Blank - Master Dependent.esm",
1✔
383
            "Oblivion.esm",
1✔
384
            "Blank - Master Dependent.esp",
1✔
385
            "Blank - Different.esp",
1✔
386
            "Blàñk.esp",
1✔
387
            "Blank.esp",
1✔
388
        ];
1✔
389

1✔
390
        assert_eq!(expected_filenames, load_order.plugin_names());
1✔
391
    }
1✔
392

393
    #[test]
394
    fn load_should_empty_the_load_order_if_the_plugins_directory_does_not_exist() {
1✔
395
        let tmp_dir = tempdir().unwrap();
1✔
396
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
397
        tmp_dir.close().unwrap();
1✔
398

1✔
399
        load_order.load().unwrap();
1✔
400

1✔
401
        assert!(load_order.plugins().is_empty());
1✔
402
    }
1✔
403

404
    #[test]
405
    fn load_should_decode_active_plugins_file_from_windows_1252() {
1✔
406
        let tmp_dir = tempdir().unwrap();
1✔
407
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
408

1✔
409
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
410

1✔
411
        load_order.load().unwrap();
1✔
412
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
413

1✔
414
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
415
    }
1✔
416

417
    #[test]
418
    fn load_should_handle_crlf_and_lf_in_active_plugins_file() {
1✔
419
        let tmp_dir = tempdir().unwrap();
1✔
420
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
421

1✔
422
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm\r"]);
1✔
423

1✔
424
        load_order.load().unwrap();
1✔
425
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
426

1✔
427
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
428
    }
1✔
429

430
    #[test]
431
    fn load_should_ignore_active_plugins_file_lines_starting_with_a_hash_for_oblivion() {
1✔
432
        let tmp_dir = tempdir().unwrap();
1✔
433
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
434

1✔
435
        write_active_plugins_file(
1✔
436
            load_order.game_settings(),
1✔
437
            &["#Blank.esp", "Blàñk.esp", "Blank.esm"],
1✔
438
        );
1✔
439

1✔
440
        load_order.load().unwrap();
1✔
441
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
442

1✔
443
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
444
    }
1✔
445

446
    #[test]
447
    fn load_should_ignore_plugins_in_active_plugins_file_that_are_not_installed() {
1✔
448
        let tmp_dir = tempdir().unwrap();
1✔
449
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
450

1✔
451
        write_active_plugins_file(
1✔
452
            load_order.game_settings(),
1✔
453
            &["Blàñk.esp", "Blank.esm", "missing.esp"],
1✔
454
        );
1✔
455

1✔
456
        load_order.load().unwrap();
1✔
457
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
458

1✔
459
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
460
    }
1✔
461

462
    #[test]
463
    fn load_should_load_plugin_states_from_active_plugins_file_for_oblivion() {
1✔
464
        let tmp_dir = tempdir().unwrap();
1✔
465
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
466

1✔
467
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
468

1✔
469
        load_order.load().unwrap();
1✔
470
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
471

1✔
472
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
473
    }
1✔
474

475
    #[test]
476
    fn load_should_succeed_when_active_plugins_file_is_missing() {
1✔
477
        let tmp_dir = tempdir().unwrap();
1✔
478
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
479

1✔
480
        assert!(load_order.load().is_ok());
1✔
481
        assert!(load_order.active_plugin_names().is_empty());
1✔
482
    }
1✔
483

484
    #[test]
485
    fn load_should_load_plugin_states_from_active_plugins_file_for_morrowind() {
1✔
486
        let tmp_dir = tempdir().unwrap();
1✔
487
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
488

1✔
489
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
490

1✔
491
        load_order.load().unwrap();
1✔
492
        let expected_filenames = vec!["Blank.esm", "Blàñk.esp"];
1✔
493

1✔
494
        assert_eq!(expected_filenames, load_order.active_plugin_names());
1✔
495
    }
1✔
496

497
    #[test]
498
    fn save_should_preserve_the_existing_set_of_timestamps() {
1✔
499
        let tmp_dir = tempdir().unwrap();
1✔
500
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
501

1✔
502
        let mapper = |p: &Plugin| {
12✔
503
            p.modification_time()
12✔
504
                .duration_since(UNIX_EPOCH)
12✔
505
                .unwrap()
12✔
506
                .as_secs()
12✔
507
        };
12✔
508

509
        set_timestamps(
1✔
510
            &load_order.game_settings().plugins_directory(),
1✔
511
            &[
1✔
512
                "Blank - Master Dependent.esp",
1✔
513
                "Blank.esm",
1✔
514
                "Blank - Different.esp",
1✔
515
                "Blank.esp",
1✔
516
                load_order.game_settings().master_file(),
1✔
517
            ],
1✔
518
        );
1✔
519

1✔
520
        load_order.load().unwrap();
1✔
521

1✔
522
        let mut old_timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
523
        old_timestamps.sort();
1✔
524

1✔
525
        load_order.save().unwrap();
1✔
526

1✔
527
        let timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
528

1✔
529
        assert_eq!(old_timestamps, timestamps);
1✔
530
    }
1✔
531

532
    #[test]
533
    fn save_should_deduplicate_plugin_timestamps() {
1✔
534
        let tmp_dir = tempdir().unwrap();
1✔
535
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
536

1✔
537
        let mapper = |p: &Plugin| {
12✔
538
            p.modification_time()
12✔
539
                .duration_since(UNIX_EPOCH)
12✔
540
                .unwrap()
12✔
541
                .as_secs()
12✔
542
        };
12✔
543

544
        set_timestamps(
1✔
545
            &load_order.game_settings().plugins_directory(),
1✔
546
            &[
1✔
547
                "Blank - Master Dependent.esp",
1✔
548
                "Blank.esm",
1✔
549
                "Blank - Different.esp",
1✔
550
                "Blank.esp",
1✔
551
                load_order.game_settings().master_file(),
1✔
552
            ],
1✔
553
        );
1✔
554

1✔
555
        // Give two files the same timestamp.
1✔
556
        load_order.plugins_mut()[1]
1✔
557
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
558
            .unwrap();
1✔
559

1✔
560
        load_order.load().unwrap();
1✔
561

1✔
562
        let mut old_timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
563

1✔
564
        load_order.save().unwrap();
1✔
565

1✔
566
        let timestamps: Vec<u64> = load_order.plugins().iter().map(&mapper).collect();
1✔
567

1✔
568
        assert_ne!(old_timestamps, timestamps);
1✔
569

570
        old_timestamps.sort();
1✔
571
        old_timestamps.dedup_by_key(|t| *t);
10✔
572
        let last_timestamp = *old_timestamps.last().unwrap();
1✔
573
        old_timestamps.push(last_timestamp + 60);
1✔
574

1✔
575
        assert_eq!(old_timestamps, timestamps);
1✔
576
    }
1✔
577

578
    #[test]
579
    fn save_should_create_active_plugins_file_parent_directory_if_it_does_not_exist() {
1✔
580
        let tmp_dir = tempdir().unwrap();
1✔
581
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
582

1✔
583
        remove_dir_all(
1✔
584
            load_order
1✔
585
                .game_settings()
1✔
586
                .active_plugins_file()
1✔
587
                .parent()
1✔
588
                .unwrap(),
1✔
589
        )
1✔
590
        .unwrap();
1✔
591

1✔
592
        load_order.save().unwrap();
1✔
593

1✔
594
        assert!(load_order
1✔
595
            .game_settings()
1✔
596
            .active_plugins_file()
1✔
597
            .parent()
1✔
598
            .unwrap()
1✔
599
            .exists());
1✔
600
    }
1✔
601

602
    #[test]
603
    fn save_should_write_active_plugins_file_for_oblivion() {
1✔
604
        let tmp_dir = tempdir().unwrap();
1✔
605
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
606

1✔
607
        load_order.save().unwrap();
1✔
608

1✔
609
        load_order.load().unwrap();
1✔
610
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
611
    }
1✔
612

613
    #[test]
614
    fn save_should_write_active_plugins_file_for_morrowind() {
1✔
615
        let tmp_dir = tempdir().unwrap();
1✔
616
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
617

1✔
618
        write_active_plugins_file(load_order.game_settings(), &["Blàñk.esp", "Blank.esm"]);
1✔
619

1✔
620
        load_order.save().unwrap();
1✔
621

1✔
622
        load_order.load().unwrap();
1✔
623
        assert_eq!(vec!["Blank.esp"], load_order.active_plugin_names());
1✔
624

625
        let mut content = String::new();
1✔
626
        File::open(load_order.game_settings().active_plugins_file())
1✔
627
            .unwrap()
1✔
628
            .read_to_string(&mut content)
1✔
629
            .unwrap();
1✔
630
        assert!(content.contains("isrealmorrowindini=false\n[Game Files]\n"));
1✔
631
    }
1✔
632

633
    #[test]
634
    fn save_should_error_if_an_active_plugin_filename_cannot_be_encoded_in_windows_1252() {
1✔
635
        let tmp_dir = tempdir().unwrap();
1✔
636
        let mut load_order = prepare(GameId::Oblivion, &tmp_dir.path());
1✔
637

1✔
638
        let filename = "Bl\u{0227}nk.esm";
1✔
639
        copy_to_test_dir(
1✔
640
            "Blank - Different.esm",
1✔
641
            filename,
1✔
642
            &load_order.game_settings(),
1✔
643
        );
1✔
644
        let mut plugin = Plugin::new(filename, &load_order.game_settings()).unwrap();
1✔
645
        plugin.activate().unwrap();
1✔
646
        load_order.plugins_mut().push(plugin);
1✔
647

1✔
648
        match load_order.save().unwrap_err() {
1✔
649
            Error::EncodeError(s) => assert_eq!("Blȧnk.esm", s),
1✔
UNCOV
650
            e => panic!("Expected encode error, got {:?}", e),
×
651
        };
652
    }
1✔
653

654
    #[test]
655
    fn is_self_consistent_should_return_true() {
1✔
656
        let tmp_dir = tempdir().unwrap();
1✔
657
        let load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
658

1✔
659
        assert!(load_order.is_self_consistent().unwrap());
1✔
660
    }
1✔
661

662
    #[test]
663
    fn is_ambiguous_should_return_false_if_all_loaded_plugins_have_unique_timestamps() {
1✔
664
        let tmp_dir = tempdir().unwrap();
1✔
665
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
666

667
        for (index, plugin) in load_order.plugins_mut().iter_mut().enumerate() {
3✔
668
            plugin
3✔
669
                .set_modification_time(UNIX_EPOCH + Duration::new(index.try_into().unwrap(), 0))
3✔
670
                .unwrap();
3✔
671
        }
3✔
672

673
        assert!(!load_order.is_ambiguous().unwrap());
1✔
674
    }
1✔
675

676
    #[test]
677
    fn is_ambiguous_should_return_false_if_two_loaded_plugins_have_the_same_timestamp() {
1✔
678
        let tmp_dir = tempdir().unwrap();
1✔
679
        let mut load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
680

1✔
681
        // Give two files the same timestamp.
1✔
682
        load_order.plugins_mut()[0]
1✔
683
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
684
            .unwrap();
1✔
685
        load_order.plugins_mut()[1]
1✔
686
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
687
            .unwrap();
1✔
688

1✔
689
        assert!(!load_order.is_ambiguous().unwrap());
1✔
690
    }
1✔
691

692
    #[test]
693
    fn plugin_sorter_should_sort_in_descending_filename_order_if_timestamps_are_equal() {
1✔
694
        let tmp_dir = tempdir().unwrap();
1✔
695
        let load_order = prepare(GameId::Morrowind, &tmp_dir.path());
1✔
696

1✔
697
        let mut plugin1 = Plugin::new("Blank.esp", &load_order.game_settings()).unwrap();
1✔
698
        let mut plugin2 =
1✔
699
            Plugin::new("Blank - Different.esp", &load_order.game_settings()).unwrap();
1✔
700

1✔
701
        plugin1
1✔
702
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
703
            .unwrap();
1✔
704

1✔
705
        plugin2
1✔
706
            .set_modification_time(UNIX_EPOCH + Duration::new(2, 0))
1✔
707
            .unwrap();
1✔
708

1✔
709
        let ordering = plugin_sorter(&plugin1, &plugin2);
1✔
710

1✔
711
        assert_eq!(Ordering::Less, ordering);
1✔
712
    }
1✔
713
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc