Skip to main content

rsynko_media/
laws.rs

1//! Law scenarios, stated once over the capabilities.
2//!
3//! A scenario provides its own data. It builds every value through the algebras it is bound to, so
4//! it constrains any interpreter of those sorts and a runner names it without supplying anything.
5//! Where a law needs state only an interpreter can hold — a populated extractor catalog — the
6//! interpreter supplies it through a fixture capability, and the scenario still authors the rest.
7
8use crate::*;
9use alux_ext::ext;
10use anyhow::{Result, bail};
11use rsynko_download::*;
12use std::fmt::{Debug, Display};
13use std::path::{Path, PathBuf};
14
15/// Supplies the catalog-dependent inputs an extraction scenario cannot author for itself.
16pub trait ExtractionLawFixture {
17    /// Names a URL the interpreter's catalog accepts.
18    fn accepted_law_url(&self) -> String;
19    /// Names a URL no catalog entry accepts.
20    fn unsupported_law_url(&self) -> String;
21    /// Names the extractors applied so far, in application order.
22    fn law_applied_extractors(&self) -> Vec<String>;
23}
24
25/// Authors the open-record laws.
26#[ext(name = ObservationLaws)]
27pub impl<This> This
28where
29    This: MetadataAlg,
30{
31    /// Checks that the empty record is neutral and later equal keys replace earlier ones.
32    ///
33    /// # Errors
34    ///
35    /// Returns the first violated law.
36    fn observation_laws(&self) -> Result<()>
37    where
38        This::Metadata: PartialEq + Debug,
39        This::Value: Clone,
40    {
41        if self.metadata([]) != self.empty_metadata() {
42            bail!("neutrality violated: a record of no fields differs from the empty record");
43        }
44        let stated = self.string_metadata("first");
45        if self.metadata([("title".to_owned(), stated.clone())])
46            != self.text_metadata("title", "first")
47        {
48            bail!("single-field construction disagrees with its derived form");
49        }
50        let replaced = self.metadata([
51            ("title".to_owned(), stated),
52            ("title".to_owned(), self.string_metadata("second")),
53        ]);
54        if replaced != self.text_metadata("title", "second") {
55            bail!("replacement violated: a later equal key did not replace the earlier field");
56        }
57        Ok(())
58    }
59}
60
61/// Authors the selection-program laws.
62#[ext(name = SelectionLaws)]
63pub impl<This> This
64where
65    This: MetadataAlg
66        + FormatAlg
67        + FormatPredicateAlg
68        + FormatSelectionAlg
69        + FormatPredicateMatchAlg
70        + FormatSelectionApplyAlg,
71{
72    /// Checks best, worst, merge, and fallback against a catalog the scenario authors.
73    ///
74    /// # Errors
75    ///
76    /// Returns the first violated law.
77    fn selection_laws(&self) -> Result<()>
78    where
79        This::Predicate: Clone,
80    {
81        let catalog = [false, true, true].map(|video| {
82            self.format(
83                if video { "video" } else { "audio" },
84                self.metadata([(FORMAT_HAS_VIDEO.to_owned(), self.boolean_metadata(video))]),
85            )
86        });
87        self.check_selection_against(&catalog, &self.has_video())?;
88        self.check_selection_against(&catalog, &self.format_extension("never"))?;
89
90        // Height is an ordinary observation, so bounding it needs no dedicated predicate.
91        let sized = [360_i64, 720, 1080].map(|height| {
92            self.format(
93                "sized",
94                self.metadata([("height".to_owned(), self.integer_metadata(height))]),
95            )
96        });
97        self.check_selection_against(&sized, &self.at_most("height", 720.0))?;
98        self.check_selection_against(&sized, &self.greater_than("height", 720.0))?;
99        // An unstated observation satisfies no predicate speaking about it.
100        self.check_selection_against(&catalog, &self.at_most("height", 720.0))?;
101        self.check_selection_against(&catalog, &self.observed_format("height"))
102    }
103
104    /// Checks the selection laws for one catalog and predicate.
105    ///
106    /// # Errors
107    ///
108    /// Returns the first violated law.
109    fn check_selection_against(
110        &self,
111        catalog: &[This::Format],
112        predicate: &This::Predicate,
113    ) -> Result<()>
114    where
115        This::Predicate: Clone,
116    {
117        let matching: Vec<&This::Format> = catalog
118            .iter()
119            .filter(|format| self.format_matches(predicate, format))
120            .collect();
121        let best = self.select_formats(catalog, &self.best_format(predicate.clone()));
122        let worst = self.select_formats(catalog, &self.worst_format(predicate.clone()));
123
124        let (Some(first), Some(last)) = (matching.first(), matching.last()) else {
125            if best.is_some() || worst.is_some() {
126                bail!("selection chose a format although nothing matches the predicate");
127            }
128            return Ok(());
129        };
130        if !chose(best.as_deref(), last) {
131            bail!("Best did not choose the most preferred matching format");
132        }
133        if !chose(worst.as_deref(), first) {
134            bail!("Worst did not choose the least preferred matching format");
135        }
136
137        let merged = self.select_formats(
138            catalog,
139            &self.merge_formats([
140                self.best_format(predicate.clone()),
141                self.worst_format(predicate.clone()),
142            ]),
143        );
144        let Some(merged) = merged else {
145            bail!("Merge failed although every child succeeded");
146        };
147        if merged.len() != 2 || !std::ptr::eq(merged[0], *last) || !std::ptr::eq(merged[1], *first)
148        {
149            bail!("Merge did not preserve child-program order");
150        }
151
152        let fallback = self.select_formats(
153            catalog,
154            &self.fallback_formats([
155                self.best_format(predicate.clone()),
156                self.worst_format(predicate.clone()),
157            ]),
158        );
159        if !chose(fallback.as_deref(), last) {
160            bail!("Fallback was not left-biased");
161        }
162        Ok(())
163    }
164}
165
166/// Observes whether a selection chose exactly the expected format.
167fn chose<Format>(selected: Option<&[&Format]>, expected: &Format) -> bool {
168    selected.is_some_and(|chosen| chosen.len() == 1 && std::ptr::eq(chosen[0], expected))
169}
170
171/// Authors the format-observation laws.
172#[ext(name = FormatLaws)]
173pub impl<This> This
174where
175    This: MetadataAlg + FormatAlg + FormatViewAlg,
176{
177    /// Checks that a format states back exactly what it was defined with.
178    ///
179    /// The laws checked are:
180    ///
181    /// 1. an observation a format states is the one it is observed under;
182    /// 2. distinct observation names are independent;
183    /// 3. an unstated observation is absent rather than defaulted.
184    ///
185    /// # Errors
186    ///
187    /// Returns the first violated law.
188    fn format_laws(&self) -> Result<()> {
189        let source = "https://media.example/a.mp4";
190        let stated = self.format(
191            "18",
192            self.metadata([
193                (FORMAT_SOURCE.to_owned(), self.string_metadata(source)),
194                (FORMAT_EXTENSION.to_owned(), self.string_metadata("mp4")),
195            ]),
196        );
197        if self.format_text(&stated, FORMAT_SOURCE) != Some(source) {
198            bail!("a stated source is not the one the format is observed under");
199        }
200        if self.format_text(&stated, FORMAT_EXTENSION) != Some("mp4") {
201            bail!("stating a source disturbed the stated extension");
202        }
203        if let Some(observed) = self.format_text(&stated, "quality") {
204            bail!("an unstated observation was observed as {observed}");
205        }
206        let bare = self.format("18", self.empty_metadata());
207        if self.format_text(&bare, FORMAT_SOURCE).is_some() {
208            bail!("a format stating nothing still locates its bytes");
209        }
210        Ok(())
211    }
212}
213
214/// Authors the produced-artifact laws.
215#[ext(name = ArtifactLaws)]
216pub impl<This> This
217where
218    This: MetadataAlg + ArtifactAlg,
219{
220    /// Checks that an artifact is exactly its identity, its role, and its observations.
221    ///
222    /// The laws checked are:
223    ///
224    /// 1. definition is a function of identity, role, and observations;
225    /// 2. each of the three is significant, so changing one changes the artifact.
226    ///
227    /// # Errors
228    ///
229    /// Returns the first violated law.
230    fn artifact_laws(&self) -> Result<()>
231    where
232        This::Artifact: PartialEq + Debug,
233        This::Metadata: Clone,
234    {
235        let resting = self.metadata([(
236            ARTIFACT_LOCATION.to_owned(),
237            self.string_metadata("temporary.part"),
238        )]);
239        let stated = self.artifact("media", ArtifactKind::Media, resting.clone());
240        if stated != self.artifact("media", ArtifactKind::Media, resting.clone()) {
241            bail!("definition is not a function of identity, role, and observations");
242        }
243        for (name, other) in [
244            (
245                "identity",
246                self.artifact("subtitle", ArtifactKind::Media, resting.clone()),
247            ),
248            (
249                "role",
250                self.artifact("media", ArtifactKind::Subtitle, resting),
251            ),
252            (
253                "observations",
254                self.artifact("media", ArtifactKind::Media, self.empty_metadata()),
255            ),
256        ] {
257            if stated == other {
258                bail!("artifact {name} is not significant: {stated:?} equals {other:?}");
259            }
260        }
261        Ok(())
262    }
263}
264
265/// Supplies the application trace a processing scenario cannot author for itself.
266pub trait ProcessingLawFixture {
267    /// Names the processors applied, in application order.
268    fn law_applied(&self) -> Vec<String>;
269    /// Refuses the named processor, so application fails when it is reached.
270    fn refuse_law_processor(&mut self, id: &str);
271}
272
273/// Authors the processing-application laws.
274#[ext(name = ProcessingApplicationLaws)]
275pub impl<This> This
276where
277    This:
278        ProcessingProgramAlg + ProcessingProgramViewAlg + ProcessingApplyAlg + ProcessingLawFixture,
279{
280    /// Checks that interpreting one stage applies its subsequence in declaration order.
281    ///
282    /// # Errors
283    ///
284    /// Returns the violated law, or the interpreter's application failure.
285    fn processing_stage_laws(&mut self) -> Result<()>
286    where
287        This::Error: Debug,
288    {
289        let program = self.processing([
290            self.processing_step(ProcessingStage::PreProcess, self.processor("before")),
291            self.processing_step(ProcessingStage::AfterMove, self.processor("first")),
292            self.processing_step(ProcessingStage::AfterMove, self.processor("second")),
293        ]);
294        if let Err(error) = self.run_processing_stage(&program, ProcessingStage::AfterMove) {
295            bail!("stage interpretation failed: {error:?}");
296        }
297        let applied = self.law_applied();
298        if applied != ["first", "second"] {
299            bail!("stage selection did not preserve relative declaration order: {applied:?}");
300        }
301        Ok(())
302    }
303
304    /// Checks that a failing step stops interpretation before every later step.
305    ///
306    /// # Errors
307    ///
308    /// Returns the violated law.
309    fn processing_failure_laws(&mut self) -> Result<()> {
310        let program = self.processing([
311            self.processing_step(ProcessingStage::PostProcess, self.processor("first")),
312            self.processing_step(ProcessingStage::PostProcess, self.processor("refused")),
313            self.processing_step(ProcessingStage::PostProcess, self.processor("never")),
314        ]);
315        self.refuse_law_processor("refused");
316        if self.run_processing_program(&program).is_ok() {
317            bail!("a refused step did not fail the program");
318        }
319        let applied = self.law_applied();
320        if applied != ["first"] {
321            bail!("failure did not stop interpretation before later steps: {applied:?}");
322        }
323        Ok(())
324    }
325}
326
327/// Authors the processing-program laws.
328#[ext(name = ProcessingLaws)]
329pub impl<This> This
330where
331    This: ProcessingProgramAlg + ProcessingProgramViewAlg,
332{
333    /// Checks that the empty program is an identity and concatenation is sequential composition.
334    ///
335    /// Stage selection and failure ordering are application laws: they constrain the order steps
336    /// are *applied* in, which only an interpreter's trace can observe.
337    ///
338    /// # Errors
339    ///
340    /// Returns the first violated law.
341    fn processing_laws(&self) -> Result<()>
342    where
343        This::Step: Clone + PartialEq + Debug,
344    {
345        let authored: Vec<This::Step> = [
346            (ProcessingStage::PostProcess, "first"),
347            (ProcessingStage::AfterMove, "second"),
348        ]
349        .into_iter()
350        .map(|(stage, name)| self.processing_step(stage, self.processor(name)))
351        .collect();
352
353        if Self::processing_steps(&self.processing([]))
354            .next()
355            .is_some()
356        {
357            bail!("the empty program is not empty");
358        }
359        let program = self.processing(authored.iter().cloned());
360        let declared: Vec<This::Step> = Self::processing_steps(&program).cloned().collect();
361        if declared != authored {
362            bail!("construction did not preserve declaration order: {declared:?}");
363        }
364        for (side, composed) in [
365            (
366                "left",
367                self.then_processing(
368                    self.processing([]),
369                    self.processing(authored.iter().cloned()),
370                ),
371            ),
372            (
373                "right",
374                self.then_processing(
375                    self.processing(authored.iter().cloned()),
376                    self.processing([]),
377                ),
378            ),
379        ] {
380            let observed: Vec<This::Step> = Self::processing_steps(&composed).cloned().collect();
381            if observed != authored {
382                bail!("the empty program is not an identity on the {side}: {observed:?}");
383            }
384        }
385        let doubled = self.then_processing(
386            self.processing(authored.iter().cloned()),
387            self.processing(authored.iter().cloned()),
388        );
389        let observed: Vec<This::Step> = Self::processing_steps(&doubled).cloned().collect();
390        let expected: Vec<This::Step> = authored.iter().chain(&authored).cloned().collect();
391        if observed != expected {
392            bail!("concatenation is not sequential composition: {observed:?}");
393        }
394        Ok(())
395    }
396}
397
398/// Authors the portable-output-naming laws.
399#[ext(name = OutputNameLaws)]
400pub impl<This> This
401where
402    This: OutputNameAlg,
403{
404    /// Checks that naming is a function of its inputs and yields one safe file component.
405    ///
406    /// # Errors
407    ///
408    /// Returns the first violated law.
409    fn output_naming_laws(&self) -> Result<()> {
410        let named = portable_file_name(Some("A/B"), "id", Some("mp4"));
411        if named != portable_file_name(Some("A/B"), "id", Some("mp4")) {
412            bail!("naming is not a function of title, fallback, and extension");
413        }
414        if portable_file_name(None, "id", Some("mp4"))
415            != portable_file_name(Some("   "), "id", Some("mp4"))
416        {
417            bail!("a blank title did not select the stated fallback identity");
418        }
419        for candidate in [&named, &portable_user_file_name("a/b.mp4", Some("mp4"))] {
420            if candidate.components().count() != 1 {
421                bail!(
422                    "naming produced a path rather than one file component: {}",
423                    candidate.display()
424                );
425            }
426        }
427        if portable_user_file_name("edited.mp4", Some("bin"))
428            .extension()
429            .and_then(|value| value.to_str())
430            != Some("mp4")
431        {
432            bail!("an edited name did not keep its valid explicit extension");
433        }
434        Ok(())
435    }
436}
437
438/// Authors the ordered-extractor-choice laws over a catalog the interpreter supplies.
439#[ext(name = ExtractionLaws)]
440pub impl<This> This
441where
442    This: ExtractionCatalogAlg + ExtractionApplyAlg + ExtractionLawFixture,
443{
444    /// Checks that choice is first-accepting and that extraction follows exactly that choice.
445    ///
446    /// # Errors
447    ///
448    /// Returns the first violated law.
449    fn extraction_laws(&self) -> Result<()>
450    where
451        This::Extractor: PartialEq + Debug,
452        This::Extraction: Debug,
453        This::Error: Debug,
454    {
455        let accepted = self.accepted_law_url();
456        let unsupported = self.unsupported_law_url();
457        let expected = self
458            .extractor_keys()
459            .find(|key| self.extractor_accepts(key, &accepted));
460        let Some(expected) = expected else {
461            bail!("the fixture URL {accepted} is accepted by no catalog entry");
462        };
463        match self.choose_extractor(&accepted) {
464            Some(chosen) if chosen == expected => {}
465            other => bail!("first-accepting choice violated: chose {other:?}"),
466        }
467        if let Some(chosen) = self.choose_extractor(&unsupported) {
468            bail!("unsupported input chose {chosen:?}, expected no extractor");
469        }
470        if let Err(error) = self.extract_url(&accepted) {
471            bail!("accepted input failed to extract: {error:?}");
472        }
473        let applied = self.law_applied_extractors();
474        if applied.len() != 1 {
475            bail!("extracting a supported URL applied {applied:?}, expected exactly one extractor");
476        }
477        match self.extract_url(&unsupported) {
478            Err(ExtractUrlError::Unsupported { url }) if url == unsupported => {}
479            other => bail!("unsupported input denoted {other:?}, expected an unsupported error"),
480        }
481        if self.law_applied_extractors() != applied {
482            bail!("extracting an unsupported URL applied an extractor");
483        }
484        Ok(())
485    }
486}
487
488/// Authors the composed media-download laws.
489#[ext(name = MediaProgramLaws)]
490pub impl<This, Source, Event, Progress> This
491where
492    This: MetadataAlg
493        + FormatAlg
494        + FormatViewAlg
495        + ExtractionAlg
496        + ExtractionViewAlg
497        + MediaViewAlg
498        + FormatPredicateAlg
499        + FormatSelectionAlg
500        + FormatSelectionApplyAlg
501        + FormatSourceAlg<Source>
502        + FetchStreamAlg<Source, Error: Display>
503        + AtomicPublishAlg<Error: Display>
504        + DownloadObservationAlg<Event = Event, Progress = Progress>
505        + DownloadProgressAlg<Progress = Progress>
506        + DownloadReportAlg<Event = Event>
507        + MediaProgramLawFixture,
508{
509    /// Checks that the composed program selects one format and names its destination from it.
510    ///
511    /// The laws checked are:
512    ///
513    /// 1. a result that is not a single media item is refused before anything is retrieved;
514    /// 2. naming depends only on the extraction, the selected format, and the output target;
515    /// 3. the selected format's source reaches generic download unchanged, so the published bytes
516    ///    are exactly the ones the source denotes.
517    ///
518    /// # Errors
519    ///
520    /// Returns the first violated law.
521    fn media_program_laws(&mut self) -> Result<()> {
522        let format = self.format(
523            "18",
524            self.metadata([
525                (
526                    FORMAT_SOURCE.to_owned(),
527                    self.string_metadata(self.located_law_source()),
528                ),
529                (
530                    FORMAT_EXTENSION.to_owned(),
531                    self.string_metadata(self.law_extension()),
532                ),
533            ]),
534        );
535        let extraction = self.media("law-media", self.empty_metadata(), [format]);
536        let selection = self.best_format(self.any_format());
537
538        let published =
539            match self.download_extraction(extraction, &selection, &OutputTarget::MediaId) {
540                Ok(path) => path,
541                Err(error) => bail!("the composed program failed: {error}"),
542            };
543        let expected = PathBuf::from(format!("law-media.{}", self.law_extension()));
544        if published != expected {
545            bail!(
546                "naming derived {} rather than {}",
547                published.display(),
548                expected.display()
549            );
550        }
551        if self.law_published_bytes(&published).as_deref() != Some(&self.expected_law_bytes()[..]) {
552            bail!("the selected format's source did not reach download unchanged");
553        }
554
555        let reference = self.url_reference("https://example.test/other", None, false);
556        match self.download_extraction(reference, &selection, &OutputTarget::MediaId) {
557            Err(MediaDownloadError::NotSingleMedia) => Ok(()),
558            other => bail!("a non-media result was not refused: {}", describe(&other)),
559        }
560    }
561}
562
563/// Supplies the retrieval inputs a media-program scenario cannot author for itself.
564pub trait MediaProgramLawFixture {
565    /// Names a source the interpreter can retrieve.
566    fn located_law_source(&self) -> String;
567    /// Names the extension the scenario states on its format.
568    fn law_extension(&self) -> String;
569    /// States the exact bytes the source denotes.
570    fn expected_law_bytes(&self) -> Vec<u8>;
571    /// Observes bytes published at one path.
572    fn law_published_bytes(&self, path: &Path) -> Option<Vec<u8>>;
573}
574
575/// Describes a composed-program outcome without requiring its carriers to be printable.
576fn describe<Ok, Error: Display>(outcome: &Result<Ok, Error>) -> String {
577    match outcome {
578        Ok(_) => "a successful download".to_owned(),
579        Err(error) => error.to_string(),
580    }
581}