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