Skip to main content

immich_lib/testing/
fixtures.rs

1//! Test fixture specifications for all 32 test scenarios.
2//!
3//! Each fixture defines the images, metadata, and expected outcomes
4//! for integration testing. All images are created by transforming
5//! real base photos to ensure CLIP-based duplicate detection works.
6//!
7//! Strategy for CLIP detection:
8//! - Winner: 100% scale, 95% quality (larger dimensions, larger file)
9//! - Loser: 99% scale, 60% quality (slightly smaller, smaller file)
10//!
11//! This maintains visual similarity for CLIP while giving clear dimension winner.
12
13use chrono::{TimeZone, Utc};
14
15use super::generator::{ExifSpec, TestImage, TransformSpec};
16use super::scenarios::TestScenario;
17
18/// A complete test fixture for a scenario.
19#[derive(Debug, Clone)]
20pub struct ScenarioFixture {
21    /// The scenario this fixture tests
22    pub scenario: TestScenario,
23    /// Images in the duplicate group
24    pub images: Vec<TestImage>,
25    /// Index of expected winner (0-based)
26    pub expected_winner_index: usize,
27    /// Description of what this tests
28    pub description: String,
29}
30
31/// Returns fixture definitions for all 32 test scenarios.
32pub fn all_fixtures() -> Vec<ScenarioFixture> {
33    vec![
34        // ===== Winner Selection Scenarios (W) =====
35        w1_clear_dimension_winner(),
36        w2_same_dimensions_different_size(),
37        w3_same_dimensions_same_size(),
38        w4_some_missing_dimensions(),
39        w5_only_one_has_dimensions(),
40        w6_all_missing_dimensions(),
41        w7_three_plus_duplicates(),
42        w8_same_pixels_different_aspect(),
43        // ===== Consolidation Scenarios (C) =====
44        c1_winner_lacks_gps_loser_has(),
45        c2_winner_lacks_datetime_loser_has(),
46        c3_winner_lacks_description_loser_has(),
47        c4_winner_lacks_all_loser_has_all(),
48        c5_both_have_gps(),
49        c6_multiple_losers_contribute(),
50        c7_no_loser_has_needed(),
51        c8_winner_has_everything(),
52        // ===== Conflict Scenarios (F) =====
53        f1_gps_conflict(),
54        f2_gps_within_threshold(),
55        f3_timezone_conflict(),
56        f4_camera_conflict(),
57        f5_capture_time_conflict(),
58        f6_multiple_conflicts(),
59        f7_no_conflicts(),
60        // ===== Edge Case Scenarios (X) =====
61        x1_single_asset_group(),
62        x2_large_group(),
63        x3_large_file(),
64        x4_special_chars_filename(),
65        x5_video(),
66        x7_png(),
67        x9_unicode_description(),
68        x10_very_old_date(),
69        x11_future_date(),
70    ]
71}
72
73// ===== Winner Selection Scenarios =====
74// Each uses its own unique base image
75
76fn w1_clear_dimension_winner() -> ScenarioFixture {
77    ScenarioFixture {
78        scenario: TestScenario::W1ClearDimensionWinner,
79        images: vec![
80            TestImage::new(
81                "w1_large.jpg",
82                TransformSpec::new("base_w1.jpg")
83                    .with_scale(100)
84                    .with_quality(95),
85            ),
86            TestImage::new(
87                "w1_small.jpg",
88                TransformSpec::new("base_w1.jpg")
89                    .with_scale(99)
90                    .with_quality(60),
91            ),
92        ],
93        expected_winner_index: 0,
94        description: "Larger dimensions should win (100% vs 99% scale)".into(),
95    }
96}
97
98fn w2_same_dimensions_different_size() -> ScenarioFixture {
99    // Same dimensions but different file size via quality
100    ScenarioFixture {
101        scenario: TestScenario::W2SameDimensionsDifferentSize,
102        images: vec![
103            TestImage::new(
104                "w2_a.jpg",
105                TransformSpec::new("base_w2.jpg")
106                    .with_scale(100)
107                    .with_quality(95),
108            ),
109            TestImage::new(
110                "w2_b.jpg",
111                TransformSpec::new("base_w2.jpg")
112                    .with_scale(100)
113                    .with_quality(60),
114            ),
115        ],
116        expected_winner_index: 0, // first when dimensions tied
117        description: "Same dimensions - first in list wins on tie".into(),
118    }
119}
120
121fn w3_same_dimensions_same_size() -> ScenarioFixture {
122    ScenarioFixture {
123        scenario: TestScenario::W3SameDimensionsSameSize,
124        images: vec![
125            TestImage::new(
126                "w3_a.jpg",
127                TransformSpec::new("base_w3.jpg")
128                    .with_scale(100)
129                    .with_quality(85),
130            )
131            .with_exif(ExifSpec {
132                description: Some("W3 variant A".into()),
133                ..Default::default()
134            }),
135            TestImage::new(
136                "w3_b.jpg",
137                TransformSpec::new("base_w3.jpg")
138                    .with_scale(100)
139                    .with_quality(85),
140            )
141            .with_exif(ExifSpec {
142                description: Some("W3 variant B".into()),
143                ..Default::default()
144            }),
145        ],
146        expected_winner_index: 0,
147        description: "Identical dimensions and size - first wins".into(),
148    }
149}
150
151fn w4_some_missing_dimensions() -> ScenarioFixture {
152    ScenarioFixture {
153        scenario: TestScenario::W4SomeMissingDimensions,
154        images: vec![
155            TestImage::new(
156                "w4_with_dims.jpg",
157                TransformSpec::new("base_w4.jpg")
158                    .with_scale(100)
159                    .with_quality(95),
160            ),
161            TestImage::new(
162                "w4_no_dims.jpg",
163                TransformSpec::new("base_w4.jpg")
164                    .with_scale(99)
165                    .with_quality(60)
166                    .without_dimensions(),
167            ),
168        ],
169        expected_winner_index: 0,
170        description: "Asset with dimensions beats asset without".into(),
171    }
172}
173
174fn w5_only_one_has_dimensions() -> ScenarioFixture {
175    ScenarioFixture {
176        scenario: TestScenario::W5OnlyOneHasDimensions,
177        images: vec![
178            TestImage::new(
179                "w5_no_dims.jpg",
180                TransformSpec::new("base_w5.jpg")
181                    .with_scale(100)
182                    .with_quality(95)
183                    .without_dimensions(),
184            ),
185            TestImage::new(
186                "w5_with_dims.jpg",
187                TransformSpec::new("base_w5.jpg")
188                    .with_scale(99)
189                    .with_quality(60),
190            ),
191        ],
192        expected_winner_index: 1, // second has dimensions
193        description: "Only second asset has dimensions - it wins".into(),
194    }
195}
196
197fn w6_all_missing_dimensions() -> ScenarioFixture {
198    ScenarioFixture {
199        scenario: TestScenario::W6AllMissingDimensions,
200        images: vec![
201            TestImage::new(
202                "w6_a.jpg",
203                TransformSpec::new("base_w6.jpg")
204                    .with_scale(100)
205                    .with_quality(95)
206                    .without_dimensions(),
207            ),
208            TestImage::new(
209                "w6_b.jpg",
210                TransformSpec::new("base_w6.jpg")
211                    .with_scale(99)
212                    .with_quality(60)
213                    .without_dimensions(),
214            ),
215        ],
216        expected_winner_index: 0,
217        description: "No dimensions on any - first wins".into(),
218    }
219}
220
221fn w7_three_plus_duplicates() -> ScenarioFixture {
222    ScenarioFixture {
223        scenario: TestScenario::W7ThreePlusDuplicates,
224        images: vec![
225            TestImage::new(
226                "w7_small.jpg",
227                TransformSpec::new("base_w7.jpg")
228                    .with_scale(97)
229                    .with_quality(50),
230            ),
231            TestImage::new(
232                "w7_large.jpg",
233                TransformSpec::new("base_w7.jpg")
234                    .with_scale(100)
235                    .with_quality(95),
236            ),
237            TestImage::new(
238                "w7_medium.jpg",
239                TransformSpec::new("base_w7.jpg")
240                    .with_scale(98)
241                    .with_quality(70),
242            ),
243        ],
244        expected_winner_index: 1, // largest
245        description: "3 duplicates - largest dimensions wins".into(),
246    }
247}
248
249fn w8_same_pixels_different_aspect() -> ScenarioFixture {
250    // Use explicit dimensions to control aspect ratio
251    ScenarioFixture {
252        scenario: TestScenario::W8SamePixelsDifferentAspect,
253        images: vec![
254            TestImage::new(
255                "w8_wide.jpg",
256                TransformSpec::new("base_w8.jpg")
257                    .with_size(600, 400)
258                    .with_quality(95),
259            ),
260            TestImage::new(
261                "w8_tall.jpg",
262                TransformSpec::new("base_w8.jpg")
263                    .with_size(594, 396)
264                    .with_quality(60),
265            ),
266        ],
267        expected_winner_index: 0, // first has more pixels
268        description: "Similar pixel count, different aspect - larger wins".into(),
269    }
270}
271
272// ===== Consolidation Scenarios =====
273
274fn c1_winner_lacks_gps_loser_has() -> ScenarioFixture {
275    ScenarioFixture {
276        scenario: TestScenario::C1WinnerLacksGpsLoserHas,
277        images: vec![
278            TestImage::new(
279                "c1_winner_no_gps.jpg",
280                TransformSpec::new("base_c1.jpg")
281                    .with_scale(100)
282                    .with_quality(95),
283            ),
284            TestImage::new(
285                "c1_loser_has_gps.jpg",
286                TransformSpec::new("base_c1.jpg")
287                    .with_scale(99)
288                    .with_quality(60),
289            )
290            .with_exif(ExifSpec {
291                gps: Some((51.5074, -0.1278)), // London
292                ..Default::default()
293            }),
294        ],
295        expected_winner_index: 0,
296        description: "Winner lacks GPS, loser has it - consolidate GPS".into(),
297    }
298}
299
300fn c2_winner_lacks_datetime_loser_has() -> ScenarioFixture {
301    ScenarioFixture {
302        scenario: TestScenario::C2WinnerLacksDatetimeLoserHas,
303        images: vec![
304            TestImage::new(
305                "c2_winner_no_dt.jpg",
306                TransformSpec::new("base_c2.jpg")
307                    .with_scale(100)
308                    .with_quality(95),
309            ),
310            TestImage::new(
311                "c2_loser_has_dt.jpg",
312                TransformSpec::new("base_c2.jpg")
313                    .with_scale(99)
314                    .with_quality(60),
315            )
316            .with_exif(ExifSpec {
317                datetime: Some(Utc.with_ymd_and_hms(2024, 6, 15, 14, 30, 0).unwrap()),
318                ..Default::default()
319            }),
320        ],
321        expected_winner_index: 0,
322        description: "Winner lacks datetime, loser has it".into(),
323    }
324}
325
326fn c3_winner_lacks_description_loser_has() -> ScenarioFixture {
327    ScenarioFixture {
328        scenario: TestScenario::C3WinnerLacksDescriptionLoserHas,
329        images: vec![
330            TestImage::new(
331                "c3_winner_no_desc.jpg",
332                TransformSpec::new("base_c3.jpg")
333                    .with_scale(100)
334                    .with_quality(95),
335            ),
336            TestImage::new(
337                "c3_loser_has_desc.jpg",
338                TransformSpec::new("base_c3.jpg")
339                    .with_scale(99)
340                    .with_quality(60),
341            )
342            .with_exif(ExifSpec {
343                description: Some("Delicious salad".into()),
344                ..Default::default()
345            }),
346        ],
347        expected_winner_index: 0,
348        description: "Winner lacks description, loser has it".into(),
349    }
350}
351
352fn c4_winner_lacks_all_loser_has_all() -> ScenarioFixture {
353    ScenarioFixture {
354        scenario: TestScenario::C4WinnerLacksAllLoserHasAll,
355        images: vec![
356            TestImage::new(
357                "c4_winner_bare.jpg",
358                TransformSpec::new("base_c4.jpg")
359                    .with_scale(100)
360                    .with_quality(95),
361            ),
362            TestImage::new(
363                "c4_loser_rich.jpg",
364                TransformSpec::new("base_c4.jpg")
365                    .with_scale(99)
366                    .with_quality(60),
367            )
368            .with_exif(ExifSpec {
369                gps: Some((40.7128, -74.0060)), // NYC
370                datetime: Some(Utc.with_ymd_and_hms(2023, 12, 25, 10, 0, 0).unwrap()),
371                timezone: Some("-05:00".into()),
372                camera_make: Some("Canon".into()),
373                camera_model: Some("EOS R5".into()),
374                description: Some("Lion at the zoo".into()),
375            }),
376        ],
377        expected_winner_index: 0,
378        description: "Winner has no metadata, loser has everything".into(),
379    }
380}
381
382fn c5_both_have_gps() -> ScenarioFixture {
383    let gps = Some((48.8566, 2.3522)); // Paris
384    ScenarioFixture {
385        scenario: TestScenario::C5BothHaveGps,
386        images: vec![
387            TestImage::new(
388                "c5_a_gps.jpg",
389                TransformSpec::new("base_c5.jpg")
390                    .with_scale(100)
391                    .with_quality(95),
392            )
393            .with_exif(ExifSpec {
394                gps,
395                ..Default::default()
396            }),
397            TestImage::new(
398                "c5_b_gps.jpg",
399                TransformSpec::new("base_c5.jpg")
400                    .with_scale(99)
401                    .with_quality(60),
402            )
403            .with_exif(ExifSpec {
404                gps,
405                ..Default::default()
406            }),
407        ],
408        expected_winner_index: 0,
409        description: "Both have same GPS - no consolidation needed".into(),
410    }
411}
412
413fn c6_multiple_losers_contribute() -> ScenarioFixture {
414    ScenarioFixture {
415        scenario: TestScenario::C6MultipleLosersContribute,
416        images: vec![
417            TestImage::new(
418                "c6_winner.jpg",
419                TransformSpec::new("base_c6.jpg")
420                    .with_scale(100)
421                    .with_quality(95),
422            ),
423            TestImage::new(
424                "c6_loser_gps.jpg",
425                TransformSpec::new("base_c6.jpg")
426                    .with_scale(99)
427                    .with_quality(65),
428            )
429            .with_exif(ExifSpec {
430                gps: Some((35.6762, 139.6503)), // Tokyo
431                ..Default::default()
432            }),
433            TestImage::new(
434                "c6_loser_dt.jpg",
435                TransformSpec::new("base_c6.jpg")
436                    .with_scale(98)
437                    .with_quality(55),
438            )
439            .with_exif(ExifSpec {
440                datetime: Some(Utc.with_ymd_and_hms(2024, 3, 20, 9, 0, 0).unwrap()),
441                ..Default::default()
442            }),
443        ],
444        expected_winner_index: 0,
445        description: "Multiple losers contribute different metadata".into(),
446    }
447}
448
449fn c7_no_loser_has_needed() -> ScenarioFixture {
450    ScenarioFixture {
451        scenario: TestScenario::C7NoLoserHasNeeded,
452        images: vec![
453            TestImage::new(
454                "c7_winner.jpg",
455                TransformSpec::new("base_c7.jpg")
456                    .with_scale(100)
457                    .with_quality(95),
458            ),
459            TestImage::new(
460                "c7_loser.jpg",
461                TransformSpec::new("base_c7.jpg")
462                    .with_scale(99)
463                    .with_quality(60),
464            ),
465        ],
466        expected_winner_index: 0,
467        description: "Winner lacks GPS but no loser has it either".into(),
468    }
469}
470
471fn c8_winner_has_everything() -> ScenarioFixture {
472    ScenarioFixture {
473        scenario: TestScenario::C8WinnerHasEverything,
474        images: vec![
475            TestImage::new(
476                "c8_winner_full.jpg",
477                TransformSpec::new("base_c8.jpg")
478                    .with_scale(100)
479                    .with_quality(95),
480            )
481            .with_exif(ExifSpec {
482                gps: Some((37.7749, -122.4194)), // SF
483                datetime: Some(Utc.with_ymd_and_hms(2024, 7, 4, 12, 0, 0).unwrap()),
484                timezone: Some("-07:00".into()),
485                camera_make: Some("Sony".into()),
486                camera_model: Some("A7R IV".into()),
487                description: Some("Golden Gate at noon".into()),
488            }),
489            TestImage::new(
490                "c8_loser_bare.jpg",
491                TransformSpec::new("base_c8.jpg")
492                    .with_scale(99)
493                    .with_quality(60),
494            ),
495        ],
496        expected_winner_index: 0,
497        description: "Winner already has all metadata - nothing to consolidate".into(),
498    }
499}
500
501// ===== Conflict Scenarios =====
502
503fn f1_gps_conflict() -> ScenarioFixture {
504    ScenarioFixture {
505        scenario: TestScenario::F1GpsConflict,
506        images: vec![
507            TestImage::new(
508                "f1_london.jpg",
509                TransformSpec::new("base_f1.jpg")
510                    .with_scale(100)
511                    .with_quality(95),
512            )
513            .with_exif(ExifSpec {
514                gps: Some((51.5074, -0.1278)), // London
515                ..Default::default()
516            }),
517            TestImage::new(
518                "f1_paris.jpg",
519                TransformSpec::new("base_f1.jpg")
520                    .with_scale(99)
521                    .with_quality(60),
522            )
523            .with_exif(ExifSpec {
524                gps: Some((48.8566, 2.3522)), // Paris
525                ..Default::default()
526            }),
527        ],
528        expected_winner_index: 0,
529        description: "GPS conflict - London vs Paris (should flag conflict)".into(),
530    }
531}
532
533fn f2_gps_within_threshold() -> ScenarioFixture {
534    ScenarioFixture {
535        scenario: TestScenario::F2GpsWithinThreshold,
536        images: vec![
537            TestImage::new(
538                "f2_pos_a.jpg",
539                TransformSpec::new("base_f2.jpg")
540                    .with_scale(100)
541                    .with_quality(95),
542            )
543            .with_exif(ExifSpec {
544                gps: Some((51.50740, -0.12780)),
545                ..Default::default()
546            }),
547            TestImage::new(
548                "f2_pos_b.jpg",
549                TransformSpec::new("base_f2.jpg")
550                    .with_scale(99)
551                    .with_quality(60),
552            )
553            .with_exif(ExifSpec {
554                gps: Some((51.50745, -0.12785)), // ~5m away
555                ..Default::default()
556            }),
557        ],
558        expected_winner_index: 0,
559        description: "GPS within threshold - should NOT conflict".into(),
560    }
561}
562
563fn f3_timezone_conflict() -> ScenarioFixture {
564    ScenarioFixture {
565        scenario: TestScenario::F3TimezoneConflict,
566        images: vec![
567            TestImage::new(
568                "f3_tz_a.jpg",
569                TransformSpec::new("base_f3.jpg")
570                    .with_scale(100)
571                    .with_quality(95),
572            )
573            .with_exif(ExifSpec {
574                timezone: Some("+00:00".into()), // UTC
575                ..Default::default()
576            }),
577            TestImage::new(
578                "f3_tz_b.jpg",
579                TransformSpec::new("base_f3.jpg")
580                    .with_scale(99)
581                    .with_quality(60),
582            )
583            .with_exif(ExifSpec {
584                timezone: Some("-08:00".into()), // PST
585                ..Default::default()
586            }),
587        ],
588        expected_winner_index: 0,
589        description: "Timezone conflict - UTC vs PST".into(),
590    }
591}
592
593fn f4_camera_conflict() -> ScenarioFixture {
594    ScenarioFixture {
595        scenario: TestScenario::F4CameraConflict,
596        images: vec![
597            TestImage::new(
598                "f4_canon.jpg",
599                TransformSpec::new("base_f4.jpg")
600                    .with_scale(100)
601                    .with_quality(95),
602            )
603            .with_exif(ExifSpec {
604                camera_make: Some("Canon".into()),
605                camera_model: Some("EOS R5".into()),
606                ..Default::default()
607            }),
608            TestImage::new(
609                "f4_nikon.jpg",
610                TransformSpec::new("base_f4.jpg")
611                    .with_scale(99)
612                    .with_quality(60),
613            )
614            .with_exif(ExifSpec {
615                camera_make: Some("Nikon".into()),
616                camera_model: Some("Z6 II".into()),
617                ..Default::default()
618            }),
619        ],
620        expected_winner_index: 0,
621        description: "Camera conflict - Canon vs Nikon".into(),
622    }
623}
624
625fn f5_capture_time_conflict() -> ScenarioFixture {
626    ScenarioFixture {
627        scenario: TestScenario::F5CaptureTimeConflict,
628        images: vec![
629            TestImage::new(
630                "f5_morning.jpg",
631                TransformSpec::new("base_f5.jpg")
632                    .with_scale(100)
633                    .with_quality(95),
634            )
635            .with_exif(ExifSpec {
636                datetime: Some(Utc.with_ymd_and_hms(2024, 1, 15, 8, 0, 0).unwrap()),
637                ..Default::default()
638            }),
639            TestImage::new(
640                "f5_evening.jpg",
641                TransformSpec::new("base_f5.jpg")
642                    .with_scale(99)
643                    .with_quality(60),
644            )
645            .with_exif(ExifSpec {
646                datetime: Some(Utc.with_ymd_and_hms(2024, 1, 15, 20, 0, 0).unwrap()),
647                ..Default::default()
648            }),
649        ],
650        expected_winner_index: 0,
651        description: "Capture time conflict - morning vs evening".into(),
652    }
653}
654
655fn f6_multiple_conflicts() -> ScenarioFixture {
656    ScenarioFixture {
657        scenario: TestScenario::F6MultipleConflicts,
658        images: vec![
659            TestImage::new(
660                "f6_a.jpg",
661                TransformSpec::new("base_f6.jpg")
662                    .with_scale(100)
663                    .with_quality(95),
664            )
665            .with_exif(ExifSpec {
666                gps: Some((51.5074, -0.1278)), // London
667                camera_make: Some("Canon".into()),
668                timezone: Some("+00:00".into()),
669                ..Default::default()
670            }),
671            TestImage::new(
672                "f6_b.jpg",
673                TransformSpec::new("base_f6.jpg")
674                    .with_scale(99)
675                    .with_quality(60),
676            )
677            .with_exif(ExifSpec {
678                gps: Some((40.7128, -74.0060)), // NYC
679                camera_make: Some("Sony".into()),
680                timezone: Some("-05:00".into()),
681                ..Default::default()
682            }),
683        ],
684        expected_winner_index: 0,
685        description: "Multiple conflicts - GPS, camera, timezone all differ".into(),
686    }
687}
688
689fn f7_no_conflicts() -> ScenarioFixture {
690    let exif = ExifSpec {
691        gps: Some((51.5074, -0.1278)),
692        camera_make: Some("Canon".into()),
693        ..Default::default()
694    };
695    ScenarioFixture {
696        scenario: TestScenario::F7NoConflicts,
697        images: vec![
698            TestImage::new(
699                "f7_a.jpg",
700                TransformSpec::new("base_f7.jpg")
701                    .with_scale(100)
702                    .with_quality(95),
703            )
704            .with_exif(exif.clone()),
705            TestImage::new(
706                "f7_b.jpg",
707                TransformSpec::new("base_f7.jpg")
708                    .with_scale(99)
709                    .with_quality(60),
710            )
711            .with_exif(exif),
712        ],
713        expected_winner_index: 0,
714        description: "No conflicts - metadata matches".into(),
715    }
716}
717
718// ===== Edge Case Scenarios =====
719
720fn x1_single_asset_group() -> ScenarioFixture {
721    ScenarioFixture {
722        scenario: TestScenario::X1SingleAssetGroup,
723        images: vec![TestImage::new(
724            "x1_single.jpg",
725            TransformSpec::new("base_x1.jpg")
726                .with_scale(100)
727                .with_quality(90),
728        )],
729        expected_winner_index: 0,
730        description: "Single asset group - trivial case".into(),
731    }
732}
733
734fn x2_large_group() -> ScenarioFixture {
735    // Use narrow scale range (97-99%) for CLIP detection
736    // All images are very similar size, quality varies
737    let scales: [u32; 12] = [97, 97, 97, 98, 98, 98, 98, 99, 99, 99, 99, 100];
738    let qualities: [u8; 12] = [50, 55, 60, 65, 70, 75, 80, 85, 88, 91, 94, 97];
739
740    let images: Vec<TestImage> = (0..12)
741        .map(|i| {
742            TestImage::new(
743                format!("x2_dup_{:02}.jpg", i),
744                TransformSpec::new("base_x2.jpg")
745                    .with_scale(scales[i])
746                    .with_quality(qualities[i]),
747            )
748        })
749        .collect();
750
751    ScenarioFixture {
752        scenario: TestScenario::X2LargeGroup,
753        images,
754        expected_winner_index: 11, // last has highest scale (100%)
755        description: "12 duplicates - largest should win".into(),
756    }
757}
758
759fn x3_large_file() -> ScenarioFixture {
760    ScenarioFixture {
761        scenario: TestScenario::X3LargeFile,
762        images: vec![
763            TestImage::new(
764                "x3_large.jpg",
765                TransformSpec::new("base_x3.jpg")
766                    .with_scale(100)
767                    .with_quality(100),
768            ),
769            TestImage::new(
770                "x3_small.jpg",
771                TransformSpec::new("base_x3.jpg")
772                    .with_scale(99)
773                    .with_quality(60),
774            ),
775        ],
776        expected_winner_index: 0,
777        description: "Large file handling (full size, max quality)".into(),
778    }
779}
780
781fn x4_special_chars_filename() -> ScenarioFixture {
782    ScenarioFixture {
783        scenario: TestScenario::X4SpecialCharsFilename,
784        images: vec![
785            TestImage::new(
786                "x4_photo (1).jpg",
787                TransformSpec::new("base_x4.jpg")
788                    .with_scale(100)
789                    .with_quality(95),
790            ),
791            TestImage::new(
792                "x4_photo-copy_2024.jpg",
793                TransformSpec::new("base_x4.jpg")
794                    .with_scale(99)
795                    .with_quality(60),
796            ),
797        ],
798        expected_winner_index: 0,
799        description: "Filenames with spaces, parens, hyphens".into(),
800    }
801}
802
803fn x5_video() -> ScenarioFixture {
804    ScenarioFixture {
805        scenario: TestScenario::X5Video,
806        images: vec![
807            TestImage::new(
808                "x5_video_hd.mp4",
809                TransformSpec::new("base_x5.jpg").with_size(1920, 1080),
810            ),
811            TestImage::new(
812                "x5_video_sd.mp4",
813                TransformSpec::new("base_x5.jpg").with_size(640, 480),
814            ),
815        ],
816        expected_winner_index: 0,
817        description: "Video duplicates - HD vs SD".into(),
818    }
819}
820
821fn x7_png() -> ScenarioFixture {
822    ScenarioFixture {
823        scenario: TestScenario::X7Png,
824        images: vec![
825            TestImage::new(
826                "x7_image.png",
827                TransformSpec::new("base_x7.jpg").with_scale(100),
828            ),
829            TestImage::new(
830                "x7_image.jpg",
831                TransformSpec::new("base_x7.jpg")
832                    .with_scale(99)
833                    .with_quality(60),
834            ),
835        ],
836        expected_winner_index: 0,
837        description: "PNG vs JPEG - PNG larger".into(),
838    }
839}
840
841fn x9_unicode_description() -> ScenarioFixture {
842    ScenarioFixture {
843        scenario: TestScenario::X9UnicodeDescription,
844        images: vec![
845            TestImage::new(
846                "x9_unicode.jpg",
847                TransformSpec::new("base_x9.jpg")
848                    .with_scale(100)
849                    .with_quality(95),
850            )
851            .with_exif(ExifSpec {
852                description: Some("日本の桜 🌸 Cherry blossoms".into()),
853                ..Default::default()
854            }),
855            TestImage::new(
856                "x9_plain.jpg",
857                TransformSpec::new("base_x9.jpg")
858                    .with_scale(99)
859                    .with_quality(60),
860            ),
861        ],
862        expected_winner_index: 0,
863        description: "Unicode in description - Japanese, emoji".into(),
864    }
865}
866
867fn x10_very_old_date() -> ScenarioFixture {
868    ScenarioFixture {
869        scenario: TestScenario::X10VeryOldDate,
870        images: vec![
871            TestImage::new(
872                "x10_old.jpg",
873                TransformSpec::new("base_x10.jpg")
874                    .with_scale(100)
875                    .with_quality(95),
876            )
877            .with_exif(ExifSpec {
878                datetime: Some(Utc.with_ymd_and_hms(1985, 7, 20, 12, 0, 0).unwrap()),
879                ..Default::default()
880            }),
881            TestImage::new(
882                "x10_scan.jpg",
883                TransformSpec::new("base_x10.jpg")
884                    .with_scale(99)
885                    .with_quality(60),
886            ),
887        ],
888        expected_winner_index: 0,
889        description: "Very old date (1985) - film scan scenario".into(),
890    }
891}
892
893fn x11_future_date() -> ScenarioFixture {
894    ScenarioFixture {
895        scenario: TestScenario::X11FutureDate,
896        images: vec![
897            TestImage::new(
898                "x11_future.jpg",
899                TransformSpec::new("base_x11.jpg")
900                    .with_scale(100)
901                    .with_quality(95),
902            )
903            .with_exif(ExifSpec {
904                datetime: Some(Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap()),
905                ..Default::default()
906            }),
907            TestImage::new(
908                "x11_normal.jpg",
909                TransformSpec::new("base_x11.jpg")
910                    .with_scale(99)
911                    .with_quality(60),
912            )
913            .with_exif(ExifSpec {
914                datetime: Some(Utc.with_ymd_and_hms(2024, 6, 15, 14, 0, 0).unwrap()),
915                ..Default::default()
916            }),
917        ],
918        expected_winner_index: 0,
919        description: "Future date (2030) - camera clock error scenario".into(),
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn test_all_fixtures_count() {
929        let fixtures = all_fixtures();
930        assert_eq!(fixtures.len(), 32, "Should have exactly 32 fixtures");
931    }
932
933    #[test]
934    fn test_all_scenarios_covered() {
935        let fixtures = all_fixtures();
936        let all_scenarios = TestScenario::all();
937
938        for scenario in &all_scenarios {
939            let found = fixtures.iter().any(|f| f.scenario == *scenario);
940            assert!(found, "Missing fixture for scenario: {:?}", scenario);
941        }
942    }
943
944    #[test]
945    fn test_each_fixture_has_images() {
946        let fixtures = all_fixtures();
947        for fixture in &fixtures {
948            assert!(
949                !fixture.images.is_empty(),
950                "Fixture {:?} has no images",
951                fixture.scenario
952            );
953        }
954    }
955
956    #[test]
957    fn test_winner_index_valid() {
958        let fixtures = all_fixtures();
959        for fixture in &fixtures {
960            assert!(
961                fixture.expected_winner_index < fixture.images.len(),
962                "Fixture {:?} has invalid winner index {} (only {} images)",
963                fixture.scenario,
964                fixture.expected_winner_index,
965                fixture.images.len()
966            );
967        }
968    }
969}