From 0d67f5b072bda62d8a567964d0d2f174971e025c Mon Sep 17 00:00:00 2001 From: noctuum <25441068+noctuum@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:33:08 +0500 Subject: [PATCH] feat(sync): add ListBackend and its contract suite A write carries no score, so the list keeps the account's own. --- Cargo.lock | 2 + crates/core/src/list.rs | 15 ++ crates/sync/Cargo.toml | 5 + crates/sync/src/lib.rs | 201 ++++++++++++++-- crates/sync/src/memory.rs | 159 +++++++++++++ crates/sync/tests/contract/mod.rs | 266 +++++++++++++++++++++ crates/sync/tests/contract_rejects.rs | 322 ++++++++++++++++++++++++++ crates/sync/tests/fakes/mod.rs | 94 ++++++++ crates/sync/tests/memory_contract.rs | 9 + 9 files changed, 1056 insertions(+), 17 deletions(-) create mode 100644 crates/sync/src/memory.rs create mode 100644 crates/sync/tests/contract/mod.rs create mode 100644 crates/sync/tests/contract_rejects.rs create mode 100644 crates/sync/tests/fakes/mod.rs create mode 100644 crates/sync/tests/memory_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 96d8bba..c219695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,8 @@ name = "benshi-sync" version = "0.1.0" dependencies = [ "benshi-core", + "thiserror", + "tokio", ] [[package]] diff --git a/crates/core/src/list.rs b/crates/core/src/list.rs index cfc6bba..e428147 100644 --- a/crates/core/src/list.rs +++ b/crates/core/src/list.rs @@ -48,6 +48,21 @@ pub struct Entry { pub score: Option, } +/// Where an account stands on one work, as a write tells a list. +/// +/// The score is not in it: a write leaves the score as the list holds it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Write { + /// The work. + pub id: EntryId, + /// Where the account stands on it. + pub status: Status, + /// How many episodes the account has seen. + pub progress: u32, + /// How many times the account has seen the work again. + pub rewatches: u32, +} + /// What kind of release a work is. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/sync/Cargo.toml b/crates/sync/Cargo.toml index b7bc2dd..80ecd94 100644 --- a/crates/sync/Cargo.toml +++ b/crates/sync/Cargo.toml @@ -10,6 +10,11 @@ repository.workspace = true [dependencies] benshi-core = { path = "../core" } +thiserror = "2.0.21" +tokio = { version = "1.53.1", features = ["time"] } [lints] workspace = true + +[dev-dependencies] +tokio = { version = "1.53.1", features = ["macros", "rt", "test-util", "time"] } diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index 85384d4..593f6cf 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -1,32 +1,199 @@ -//! List backends: AniList, and later MAL, Shikimori and Kitsu. +//! List backends: the trait, and a list kept in memory. //! -//! Recognition yields `(title, episode)` in the release's own numbering, which -//! is all a filename honestly carries. Each backend then resolves that against -//! **its own account's list**, because services model the same work -//! differently and no identifier mapping can bridge a one-to-many split. +//! A backend reads an account's list and writes where the account stands on +//! one work, named by the number the service gives it. Recognition yields a +//! show and an episode in the release's own numbering, which is all a filename +//! honestly carries. **The rule of this crate is that the work an episode is +//! written to is resolved per account, against that account's own list**, +//! because services model the same work differently and no identifier mapping +//! can bridge a one-to-many split. Silently writing progress to the wrong +//! title is the worst failure a tracker has, and guessing is how you get +//! there. //! -//! A backend that cannot resolve an entry says so and skips the write. Silently -//! writing progress to the wrong title is the worst failure a tracker has, and -//! guessing is how you get there. -//! -//! A new backend is finished when it passes the shared contract suite, not when -//! it seems to work. +//! A new backend is finished when it passes the shared contract suite, in +//! `tests/contract`, not when it seems to work. + +pub mod memory; + +use std::time::Duration; use benshi_core::BoxError; +use benshi_core::list::{Entry, EntryId, Write}; + +/// Why a call to a list was not answered. +/// +/// [`Error::Timeout`], [`Error::Unreachable`] and [`Error::Wait`] are +/// transient: the same call can be answered later. [`Error::Refused`] and +/// [`Error::Unreadable`] are permanent for the backend. [`Error::NoWork`] +/// says nothing of the service: it is about the one write that named the +/// work. +/// +/// **A message here is a reason and never a subject**, as with the +/// detection crate's errors: the name of the backend is left to whoever +/// reports one. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The service accepted the call and did not answer within its deadline. + /// + /// The deadline is written with `{:?}`, which for a `Duration` is the + /// only form there is and happens to be the readable one: `15s`. + #[error("did not answer within {deadline:?}")] + Timeout { + /// The deadline it exceeded. + deadline: Duration, + }, + /// The service could not be reached, or failed on its side. + #[error("could not be reached: {0}")] + Unreachable(#[source] BoxError), + /// The service asked to be asked again after this long. + #[error("asked to wait {after:?}")] + Wait { + /// How long it asked for. + after: Duration, + }, + /// The service refused the call, as it refuses a token it does not take. + #[error("refused the call: {0}")] + Refused(String), + /// The service has no work under the number a write named. + #[error("has no work numbered {}", .id.0)] + NoWork { + /// The number the write named. + id: EntryId, + }, + /// The service answered with what cannot be read as an answer. + #[error("answered with what cannot be read: {0}")] + Unreadable(#[source] BoxError), +} /// A remote list a local record can be synchronised to. /// -/// Provisional: no backend is written, and the methods below are a -/// placeholder. +/// Every call carries the deadline of the backend that makes it, and a +/// service that never answers is an [`Error::Timeout`] once it has passed. +// Desugared rather than written `async fn`, because an `async fn` in a trait +// leaves the auto traits of the future it returns undeclared, and a future that +// is not `Send` cannot be spawned on a runtime of several threads. An +// implementation still writes `async fn` in its `impl`, and the compiler checks +// that the future it returns satisfies the bound. pub trait ListBackend { /// Name of this backend, as shown to the user. fn name(&self) -> &str; - /// Apply one queued operation. + /// Every entry of the account's list. + /// + /// # Errors + /// + /// An [`Error`] of the class the failure of the service is. + fn list(&self) -> impl Future, Error>> + Send; + + /// The account's entry for one work, and none where the work is not on + /// its list. + /// + /// # Errors + /// + /// An [`Error`] of the class the failure of the service is. + fn entry(&self, id: EntryId) -> impl Future, Error>> + Send; + + /// Write where the account stands on one work, adding the work to the + /// list where it is not on it. /// /// # Errors /// - /// Returns an error when the backend is unreachable, rejects the request, - /// or cannot resolve the entry the operation refers to. - fn apply(&mut self) -> Result<(), BoxError>; + /// [`Error::NoWork`] where the service has no work under the number, and + /// otherwise an [`Error`] of the class the failure of the service is. + fn write(&self, write: &Write) -> impl Future> + Send; +} + +/// The answer of a call, or [`Error::Timeout`] where it has not come within +/// the deadline. +pub(crate) async fn within( + deadline: Duration, + call: impl Future>, +) -> Result { + tokio::time::timeout(deadline, call) + .await + .unwrap_or(Err(Error::Timeout { deadline })) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use benshi_core::list::EntryId; + + use super::Error; + + #[test] + fn a_failure_reads_as_a_reason_and_leaves_the_subject_to_its_reporter() { + let reasons = [ + Error::Timeout { + deadline: Duration::from_secs(15), + }, + Error::Unreachable("connection refused".into()), + Error::Wait { + after: Duration::from_secs(30), + }, + Error::Refused("invalid token".to_owned()), + Error::NoWork { id: EntryId(9) }, + Error::Unreadable("expected value at line 1 column 1".into()), + ]; + + for failure in reasons { + // Exhaustive and empty on purpose: a variant added later stops + // the crate compiling here, in front of the list it has to join. + match &failure { + Error::Timeout { .. } + | Error::Unreachable(_) + | Error::Wait { .. } + | Error::Refused(_) + | Error::NoWork { .. } + | Error::Unreadable(_) => {} + } + + let said = failure.to_string(); + for subject in ["memory", "anilist", "AniList", "backend", "service"] { + assert!( + !said.contains(subject), + "a reason names its subject: {said}" + ); + } + assert!( + !said.contains("EntryId"), + "a Rust type name reached a message: {said}" + ); + } + } + + #[test] + fn every_failure_says_what_it_carries() { + assert_eq!( + Error::Timeout { + deadline: Duration::from_secs(15) + } + .to_string(), + "did not answer within 15s" + ); + assert_eq!( + Error::Unreachable("connection refused".into()).to_string(), + "could not be reached: connection refused" + ); + assert_eq!( + Error::Wait { + after: Duration::from_secs(30) + } + .to_string(), + "asked to wait 30s" + ); + assert_eq!( + Error::Refused("invalid token".to_owned()).to_string(), + "refused the call: invalid token" + ); + assert_eq!( + Error::NoWork { id: EntryId(9) }.to_string(), + "has no work numbered 9" + ); + assert_eq!( + Error::Unreadable("expected value at line 1 column 1".into()).to_string(), + "answered with what cannot be read: expected value at line 1 column 1" + ); + } } diff --git a/crates/sync/src/memory.rs b/crates/sync/src/memory.rs new file mode 100644 index 0000000..b3ddf3c --- /dev/null +++ b/crates/sync/src/memory.rs @@ -0,0 +1,159 @@ +//! A list kept in memory. +//! +//! The backend the contract was written against. Made to fail, it stands in +//! for a service that fails in any of the ways [`Fault`] names, without a +//! network. It keeps a list and the works the service has beside it, and +//! takes a write as a service does. + +use std::collections::BTreeMap; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use benshi_core::list::{Entry, EntryId, Write}; + +use crate::{Error, ListBackend, within}; + +/// How a backend in memory fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fault { + /// Accepts every call and never answers. + Silent, + /// Cannot be reached. + Unreachable, + /// Asks to be asked again after this long. + Wait(Duration), + /// Refuses every call. + Refused, + /// Answers with what cannot be read. + Unreadable, +} + +/// A list kept in memory. +#[derive(Debug)] +pub struct Memory { + name: String, + deadline: Duration, + fault: Option, + works: Mutex, +} + +/// What the service has: the works on the list, and the others. +#[derive(Debug)] +struct Works { + listed: BTreeMap, + unlisted: BTreeMap, +} + +impl Memory { + /// A backend named `name` whose list holds `listed`, and whose service + /// also has `unlisted`. + /// + /// Where the account stands on a work of `unlisted` is not read: a write + /// that adds the work says it, and a work added has no score. + #[must_use] + pub fn new( + name: &str, + deadline: Duration, + listed: impl IntoIterator, + unlisted: impl IntoIterator, + ) -> Self { + Self { + name: name.to_owned(), + deadline, + fault: None, + works: Mutex::new(Works { + listed: by_work(listed), + unlisted: by_work(unlisted), + }), + } + } + + /// The same backend, failing every call this way. + #[must_use] + pub fn failing(self, fault: Fault) -> Self { + Self { + fault: Some(fault), + ..self + } + } + + /// Answer a call as the service would, within the deadline. + async fn answer( + &self, + answer: impl FnOnce(&mut Works) -> Result, + ) -> Result { + within(self.deadline, async { + match self.fault { + None => { + let mut works = self.works.lock().unwrap_or_else(PoisonError::into_inner); + answer(&mut works) + } + Some(Fault::Silent) => std::future::pending().await, + Some(Fault::Unreachable) => Err(Error::Unreachable("connection refused".into())), + Some(Fault::Wait(after)) => Err(Error::Wait { after }), + Some(Fault::Refused) => Err(Error::Refused("invalid token".to_owned())), + Some(Fault::Unreadable) => Err(Error::Unreadable( + "expected value at line 1 column 1".into(), + )), + } + }) + .await + } +} + +/// The entries, by the work each is for. +fn by_work(entries: impl IntoIterator) -> BTreeMap { + entries.into_iter().map(|entry| (entry.id, entry)).collect() +} + +impl ListBackend for Memory { + fn name(&self) -> &str { + &self.name + } + + async fn list(&self) -> Result, Error> { + self.answer(|works| Ok(works.listed.values().cloned().collect())) + .await + } + + async fn entry(&self, id: EntryId) -> Result, Error> { + self.answer(|works| Ok(works.listed.get(&id).cloned())) + .await + } + + async fn write(&self, write: &Write) -> Result<(), Error> { + self.answer(|works| { + let entry = match works.unlisted.remove(&write.id) { + Some(work) => works.listed.entry(write.id).or_insert(Entry { + score: None, + ..work + }), + None => works + .listed + .get_mut(&write.id) + .ok_or(Error::NoWork { id: write.id })?, + }; + entry.status = write.status; + entry.progress = write.progress; + entry.rewatches = write.rewatches; + + Ok(()) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::Memory; + use crate::ListBackend; + + #[test] + fn a_backend_in_memory_is_named_as_it_was_made() { + let memory = Memory::new("second", Duration::from_secs(15), [], []); + + assert_eq!(memory.name(), "second"); + } +} diff --git a/crates/sync/tests/contract/mod.rs b/crates/sync/tests/contract/mod.rs new file mode 100644 index 0000000..9d2c661 --- /dev/null +++ b/crates/sync/tests/contract/mod.rs @@ -0,0 +1,266 @@ +//! The contract every `ListBackend` must satisfy. +//! +//! Written against the backend in memory and run unchanged against every +//! later one. Nothing here may name a service: a check that cannot be phrased +//! without one is a check on that backend, and belongs in its own tests. +//! +//! Each check states a clause in its assertion message, and each clause has a +//! test in `contract_rejects.rs` that breaks it on purpose. A check no backend +//! can fail is a description, not a check. What a subject must hold before +//! the suite can run is said of the subject, and is not a clause. +//! +//! The backend in memory runs the suite on a paused clock, from +//! `#[tokio::test(start_paused = true)]`, so a deadline of fifteen seconds +//! passes the moment nothing else is left to run, and costs no real time. + +use std::time::Duration; + +use benshi_core::list::{Entry, EntryId, Status, Write}; +use benshi_sync::{Error, ListBackend}; +use tokio::time::{Instant, timeout}; + +/// How the service behind a backend fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Failure { + /// It accepts every call and never answers. + Silent, + /// It cannot be reached. + Unreachable, + /// It asks to be asked again after this long. + Wait(Duration), + /// It refuses every call for good. + Refused, + /// It answers with what cannot be read. + Unreadable, +} + +/// Every way a service fails, in the order the suite tries them. +pub const EVERY_FAILURE: [Failure; 5] = [ + Failure::Silent, + Failure::Unreachable, + Failure::Wait(Duration::from_secs(30)), + Failure::Refused, + Failure::Unreadable, +]; + +/// What a run needs from the caller. +pub struct Subject { + /// A backend over a service that answers. + pub backend: B, + /// A work on the list, with a score, not being watched, and with an + /// episode past the one after its progress or no count at all, so that + /// a write of one more episode is not its last and the service takes no + /// step of its own. + pub listed: EntryId, + /// A work the service has and the list does not hold. + pub unlisted: EntryId, + /// A number the service has no work under. + pub missing: EntryId, + /// How long the backend promises one call may take. + pub deadline: Duration, + /// The same backend over a service that fails in the given way. + pub failing: Box B>, +} + +/// Run every check against one backend. +/// +/// Panics on the first failure, naming the clause that was broken. +pub async fn verify(subject: Subject) { + let backend = &subject.backend; + let deadline = subject.deadline; + + let before = answered(bounded(deadline, backend.entry(subject.listed)).await) + .expect("subject: the listed work is on its list"); + assert!( + before.score.is_some(), + "subject: the listed work has a score" + ); + assert_ne!( + before.status, + Status::Watching, + "subject: the listed work is not being watched" + ); + assert!( + before + .episodes + .is_none_or(|episodes| before.progress + 1 < episodes), + "subject: the listed work has an episode past the one after its progress" + ); + + a_work_off_the_list_is_answered_as_none(&subject).await; + + let write = Write { + id: subject.listed, + status: Status::Watching, + progress: before.progress + 1, + rewatches: before.rewatches + 1, + }; + answered(bounded(deadline, backend.write(&write)).await); + let once = answered(bounded(deadline, backend.entry(subject.listed)).await) + .expect("contract: a write is read back as it was written"); + a_write_is_read_back(&write, &once); + assert_eq!( + once.score, before.score, + "contract: a write leaves the score as it was" + ); + assert_eq!( + once, + Entry { + status: write.status, + progress: write.progress, + rewatches: write.rewatches, + ..before + }, + "contract: a write changes nothing else of the entry" + ); + + answered(bounded(deadline, backend.write(&write)).await); + let twice = answered(bounded(deadline, backend.entry(subject.listed)).await); + assert_eq!( + twice.as_ref(), + Some(&once), + "contract: a write made twice leaves what one left" + ); + + let adding = Write { + id: subject.unlisted, + status: Status::Watching, + progress: 1, + rewatches: 0, + }; + answered(bounded(deadline, backend.write(&adding)).await); + let added = answered(bounded(deadline, backend.entry(subject.unlisted)).await) + .expect("contract: a write adds a work that is off the list"); + a_write_is_read_back(&adding, &added); + assert_eq!( + added.score, None, + "contract: a work a write adds has no score" + ); + + let list = answered(bounded(deadline, backend.list()).await); + for written in [&once, &added] { + assert!( + list.contains(written), + "contract: the list holds what was written, and {written:?} is not in {list:?}" + ); + } + let mut works: Vec = list.iter().map(|entry| entry.id).collect(); + works.sort_unstable(); + works.dedup(); + assert_eq!( + works.len(), + list.len(), + "contract: a work is on the list once" + ); + + a_write_to_a_work_the_service_does_not_have_is_refused(&subject, &adding).await; + + for failure in EVERY_FAILURE { + each_failure_is_answered_as_its_class(&subject, failure, &write).await; + } +} + +/// A work the service has and the list does not hold has no entry. +async fn a_work_off_the_list_is_answered_as_none(subject: &Subject) { + let answer = bounded(subject.deadline, subject.backend.entry(subject.unlisted)).await; + + assert_eq!( + answered(answer), + None, + "contract: a work off the list is answered as none" + ); + let list = answered(bounded(subject.deadline, subject.backend.list()).await); + assert!( + list.iter().all(|entry| entry.id != subject.unlisted), + "contract: a work off the list is not listed" + ); +} + +/// A write to a number the service has no work under is refused, and says +/// which number. +async fn a_write_to_a_work_the_service_does_not_have_is_refused( + subject: &Subject, + write: &Write, +) { + let nowhere = Write { + id: subject.missing, + ..*write + }; + + let answer = bounded(subject.deadline, subject.backend.write(&nowhere)).await; + + assert!( + matches!(answer, Err(Error::NoWork { id }) if id == subject.missing), + "contract: a write to a work the service does not have is answered as no work, \ + and got {answer:?}" + ); +} + +/// What an entry holds of the account's standing is what the write said. +fn a_write_is_read_back(write: &Write, entry: &Entry) { + assert_eq!( + (entry.id, entry.status, entry.progress, entry.rewatches), + (write.id, write.status, write.progress, write.rewatches), + "contract: a write is read back as it was written" + ); +} + +/// Every call to a backend over a failing service is answered as the class +/// of that failure. +async fn each_failure_is_answered_as_its_class( + subject: &Subject, + failure: Failure, + write: &Write, +) { + let failing = (subject.failing)(failure); + let deadline = subject.deadline; + + let answers = [ + bounded(deadline, failing.list()).await.err(), + bounded(deadline, failing.entry(subject.listed)).await.err(), + bounded(deadline, failing.write(write)).await.err(), + ]; + + for answer in answers { + let of_its_class = match (failure, &answer) { + (Failure::Silent, Some(Error::Timeout { deadline: missed })) => *missed == deadline, + (Failure::Wait(after), Some(Error::Wait { after: told })) => *told == after, + (Failure::Unreachable, Some(Error::Unreachable(_))) + | (Failure::Refused, Some(Error::Refused(_))) + | (Failure::Unreadable, Some(Error::Unreadable(_))) => true, + _ => false, + }; + assert!( + of_its_class, + "contract: each failure is answered as its class, and {failure:?} got {answer:?}" + ); + } +} + +/// The answer of a call to a service that answers. +fn answered(answer: Result) -> T { + answer.unwrap_or_else(|error| { + panic!("contract: a call to a service that answers succeeds, and one failed: {error}") + }) +} + +/// Make one call, and fail the run where it does not end within the +/// deadline. +/// +/// A call that never ends is cut off at twice the deadline, so that a +/// backend with no deadline fails the run rather than hangs it. +async fn bounded(deadline: Duration, call: impl Future) -> T { + let started = Instant::now(); + let Ok(answer) = timeout(deadline * 2, call).await else { + panic!( + "contract: every call ends within its deadline, and one ran past twice {deadline:?}" + ); + }; + let took = started.elapsed(); + assert!( + took <= deadline, + "contract: every call ends within its deadline, and one took {took:?} of {deadline:?}" + ); + + answer +} diff --git a/crates/sync/tests/contract_rejects.rs b/crates/sync/tests/contract_rejects.rs new file mode 100644 index 0000000..a3cc6f6 --- /dev/null +++ b/crates/sync/tests/contract_rejects.rs @@ -0,0 +1,322 @@ +//! The contract rejects a backend that breaks it. +//! +//! Every clause gets a backend that breaks it and nothing else, and a test +//! that asserts the suite notices. A check no backend can fail is a +//! description rather than a check, and the only way to tell the difference is +//! to break it on purpose. +//! +//! The `expected` string ties each test to the clause it proves, so renaming a +//! clause without its proof fails the test. + +mod contract; +mod fakes; + +use std::collections::BTreeMap; +use std::sync::{Mutex, PoisonError}; + +use benshi_core::list::{Entry, EntryId, Rating, Write}; +use benshi_sync::memory::Memory; +use benshi_sync::{Error, ListBackend}; + +use contract::Failure; +use fakes::{DEADLINE, LISTED, UNLISTED, a_film, a_series}; + +/// The one clause a backend breaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Break { + /// Answers an entry for a work that is off the list. + InventsEntries, + /// Takes a write and keeps nothing of it. + ForgetsWrites, + /// Takes the score away from a work it writes. + ClearsTheScore, + /// Renames a work it writes. + RenamesTheWork, + /// Counts one more rewatch for a write made again. + CountsAWriteMadeAgain, + /// Takes a write to a work off the list and adds nothing. + WritesOnlyTheList, + /// Scores a work a write adds. + ScoresAWorkItAdds, + /// Lists what it was given, whatever was written since. + KeepsTheListItWasGiven, + /// Lists every work twice. + ListsTwice, + /// Lists a work that is off the list. + ListsAWorkOffIt, + /// Fails a call to a service that answers. + FailsAnAnsweringService, + /// Takes a write to a number the service has no work under. + TakesAWriteToNoWork, + /// Waits on a silent service for ever. + NeverGivesUpOnSilence, + /// Gives up on a silent service half a deadline late. + GivesUpOnSilenceLate, + /// Answers a silent service as one that cannot be reached. + TakesSilenceForUnreachable, + /// Answers a silent service with a deadline other than its own. + MisstatesTheDeadline, + /// Answers a service that cannot be reached as a silent one. + TakesUnreachableForSilence, + /// Answers a wait with twice the wait the service asked for. + DoublesTheWait, + /// Answers a refusal as a service that cannot be reached. + TakesARefusalForUnreachable, + /// Answers what cannot be read as a refusal. + TakesTheUnreadableForARefusal, +} + +/// The backend in memory with one clause broken. +struct Broken { + memory: Memory, + failure: Option, + breaking: Break, + /// The last write to each work, and how many times it was made again. + written: Mutex>, +} + +impl Broken { + fn new(memory: Memory, failure: Option, breaking: Break) -> Self { + Self { + memory, + failure, + breaking, + written: Mutex::new(BTreeMap::new()), + } + } + + /// The answer a break gives in place of the failure the backend stands + /// in for, where it gives one. + async fn misanswered(&self) -> Option { + match (self.breaking, self.failure?) { + (Break::NeverGivesUpOnSilence, Failure::Silent) => std::future::pending().await, + (Break::GivesUpOnSilenceLate, Failure::Silent) => { + tokio::time::sleep(DEADLINE * 3 / 2).await; + Some(Error::Timeout { deadline: DEADLINE }) + } + (Break::TakesSilenceForUnreachable, Failure::Silent) + | (Break::TakesARefusalForUnreachable, Failure::Refused) => { + Some(Error::Unreachable("connection refused".into())) + } + (Break::MisstatesTheDeadline, Failure::Silent) => Some(Error::Timeout { + deadline: DEADLINE * 2, + }), + (Break::TakesUnreachableForSilence, Failure::Unreachable) => { + Some(Error::Timeout { deadline: DEADLINE }) + } + (Break::DoublesTheWait, Failure::Wait(after)) => Some(Error::Wait { after: after * 2 }), + (Break::TakesTheUnreadableForARefusal, Failure::Unreadable) => { + Some(Error::Refused("invalid token".to_owned())) + } + _ => None, + } + } + + /// An entry as this backend answers it. + fn broken(&self, mut entry: Entry) -> Entry { + let written = self.written.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(&(_, again)) = written.get(&entry.id) { + match self.breaking { + Break::ClearsTheScore => entry.score = None, + Break::RenamesTheWork => "Another Title".clone_into(&mut entry.title), + Break::CountsAWriteMadeAgain => entry.rewatches += again, + Break::ScoresAWorkItAdds if entry.id == UNLISTED => entry.score = Rating::new(50), + _ => {} + } + } + entry + } +} + +impl ListBackend for Broken { + fn name(&self) -> &str { + self.memory.name() + } + + async fn list(&self) -> Result, Error> { + if let Some(error) = self.misanswered().await { + return Err(error); + } + let list = match self.breaking { + Break::KeepsTheListItWasGiven => vec![a_series(LISTED)], + _ => self.memory.list().await?, + }; + let list = match self.breaking { + Break::ListsTwice => [list.clone(), list].concat(), + Break::ListsAWorkOffIt if list.iter().all(|entry| entry.id != UNLISTED) => { + [list, vec![a_film(UNLISTED)]].concat() + } + _ => list, + }; + + Ok(list.into_iter().map(|entry| self.broken(entry)).collect()) + } + + async fn entry(&self, id: EntryId) -> Result, Error> { + if let Some(error) = self.misanswered().await { + return Err(error); + } + if self.breaking == Break::FailsAnAnsweringService && self.failure.is_none() { + return Err(Error::Unreachable("connection refused".into())); + } + let entry = match (self.breaking, self.memory.entry(id).await?) { + (Break::InventsEntries, None) => Some(a_film(id)), + (_, entry) => entry, + }; + + Ok(entry.map(|entry| self.broken(entry))) + } + + async fn write(&self, write: &Write) -> Result<(), Error> { + if let Some(error) = self.misanswered().await { + return Err(error); + } + match self.breaking { + Break::ForgetsWrites => return Ok(()), + Break::WritesOnlyTheList if self.memory.entry(write.id).await?.is_none() => { + return Ok(()); + } + _ => {} + } + match self.memory.write(write).await { + Err(Error::NoWork { .. }) if self.breaking == Break::TakesAWriteToNoWork => {} + answer => answer?, + } + let mut written = self.written.lock().unwrap_or_else(PoisonError::into_inner); + let again = match written.get(&write.id) { + Some(&(last, again)) if last == *write => again + 1, + _ => 0, + }; + written.insert(write.id, (*write, again)); + + Ok(()) + } +} + +/// Run the suite against the backend in memory with one clause broken. +async fn verify_broken(breaking: Break) { + contract::verify(fakes::subject(move |memory, failure| { + Broken::new(memory, failure, breaking) + })) + .await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a work off the list is answered as none")] +async fn an_entry_invented_for_a_work_off_the_list_fails_the_contract() { + verify_broken(Break::InventsEntries).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write is read back as it was written")] +async fn a_write_forgotten_fails_the_contract() { + verify_broken(Break::ForgetsWrites).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write leaves the score as it was")] +async fn a_score_taken_away_by_a_write_fails_the_contract() { + verify_broken(Break::ClearsTheScore).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write changes nothing else of the entry")] +async fn a_work_renamed_by_a_write_fails_the_contract() { + verify_broken(Break::RenamesTheWork).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write made twice leaves what one left")] +async fn a_write_made_again_counted_again_fails_the_contract() { + verify_broken(Break::CountsAWriteMadeAgain).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write adds a work that is off the list")] +async fn a_write_that_adds_nothing_fails_the_contract() { + verify_broken(Break::WritesOnlyTheList).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a work a write adds has no score")] +async fn a_work_added_with_a_score_fails_the_contract() { + verify_broken(Break::ScoresAWorkItAdds).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "the list holds what was written")] +async fn a_list_that_keeps_what_it_was_given_fails_the_contract() { + verify_broken(Break::KeepsTheListItWasGiven).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a work off the list is not listed")] +async fn a_list_that_holds_a_work_off_it_fails_the_contract() { + verify_broken(Break::ListsAWorkOffIt).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a call to a service that answers succeeds")] +async fn a_call_failed_while_the_service_answers_fails_the_contract() { + verify_broken(Break::FailsAnAnsweringService).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a work is on the list once")] +async fn a_list_that_holds_a_work_twice_fails_the_contract() { + verify_broken(Break::ListsTwice).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "a write to a work the service does not have is answered as no work")] +async fn a_write_to_no_work_taken_fails_the_contract() { + verify_broken(Break::TakesAWriteToNoWork).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "every call ends within its deadline")] +async fn a_call_that_waits_on_silence_for_ever_fails_the_contract() { + verify_broken(Break::NeverGivesUpOnSilence).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "every call ends within its deadline")] +async fn a_call_that_gives_up_late_fails_the_contract() { + verify_broken(Break::GivesUpOnSilenceLate).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Silent")] +async fn silence_taken_for_a_service_out_of_reach_fails_the_contract() { + verify_broken(Break::TakesSilenceForUnreachable).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Silent")] +async fn a_deadline_misstated_fails_the_contract() { + verify_broken(Break::MisstatesTheDeadline).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Unreachable")] +async fn a_service_out_of_reach_taken_for_silence_fails_the_contract() { + verify_broken(Break::TakesUnreachableForSilence).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Wait")] +async fn a_wait_doubled_fails_the_contract() { + verify_broken(Break::DoublesTheWait).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Refused")] +async fn a_refusal_taken_for_a_service_out_of_reach_fails_the_contract() { + verify_broken(Break::TakesARefusalForUnreachable).await; +} + +#[tokio::test(start_paused = true)] +#[should_panic(expected = "each failure is answered as its class, and Unreadable")] +async fn an_unreadable_answer_taken_for_a_refusal_fails_the_contract() { + verify_broken(Break::TakesTheUnreadableForARefusal).await; +} diff --git a/crates/sync/tests/fakes/mod.rs b/crates/sync/tests/fakes/mod.rs new file mode 100644 index 0000000..bd7a0f1 --- /dev/null +++ b/crates/sync/tests/fakes/mod.rs @@ -0,0 +1,94 @@ +//! Building blocks shared by the backends that exercise the contract. +//! +//! Entries, and the backend in memory they are listed in. The two test +//! binaries that use these wrap it differently: one runs it as it is, and the +//! other breaks one clause at a time. + +use std::time::Duration; + +use benshi_core::list::{Entry, EntryId, Format, Rating, Status}; +use benshi_sync::memory::{Fault, Memory}; + +use crate::contract::{Failure, Subject}; + +/// The deadline the backends here promise, and which the suite measures +/// against. +pub const DEADLINE: Duration = Duration::from_secs(15); + +/// A work on the list. +pub const LISTED: EntryId = EntryId(21); + +/// A work the service has and the list does not hold. +pub const UNLISTED: EntryId = EntryId(437); + +/// A number the service has no work under. +pub const MISSING: EntryId = EntryId(9); + +/// A series of twelve episodes, set aside after the third, and scored. +pub fn a_series(id: EntryId) -> Entry { + Entry { + id, + title: "Show Title".to_owned(), + spellings: vec!["Show Title Spelt Otherwise".to_owned()], + format: Some(Format::Tv), + episodes: Some(12), + episode_length: Some(Duration::from_mins(24)), + year: Some(2024), + released: true, + status: Status::OnHold, + progress: 3, + rewatches: 0, + score: Rating::new(80), + } +} + +/// A film, as the service has it before it is on the list. +/// +/// It carries a score, which a backend in memory does not read: a work a +/// write adds has none, and a backend that kept this one would show it. +pub fn a_film(id: EntryId) -> Entry { + Entry { + id, + title: "A Film".to_owned(), + spellings: Vec::new(), + format: Some(Format::Film), + episodes: Some(1), + episode_length: Some(Duration::from_mins(95)), + year: Some(1988), + released: true, + status: Status::Planned, + progress: 0, + rewatches: 0, + score: Rating::new(50), + } +} + +/// A backend in memory whose list holds the series, and whose service also +/// has the film. +pub fn memory() -> Memory { + Memory::new("memory", DEADLINE, [a_series(LISTED)], [a_film(UNLISTED)]) +} + +/// The fault a backend in memory stands in for a failure with. +pub fn fault(failure: Failure) -> Fault { + match failure { + Failure::Silent => Fault::Silent, + Failure::Unreachable => Fault::Unreachable, + Failure::Wait(after) => Fault::Wait(after), + Failure::Refused => Fault::Refused, + Failure::Unreadable => Fault::Unreadable, + } +} + +/// A subject over a backend in memory, made into the backend under test by +/// `wrap`, which is told the failure the backend stands in for, if any. +pub fn subject(wrap: impl Fn(Memory, Option) -> B + 'static) -> Subject { + Subject { + backend: wrap(memory(), None), + listed: LISTED, + unlisted: UNLISTED, + missing: MISSING, + deadline: DEADLINE, + failing: Box::new(move |failure| wrap(memory().failing(fault(failure)), Some(failure))), + } +} diff --git a/crates/sync/tests/memory_contract.rs b/crates/sync/tests/memory_contract.rs new file mode 100644 index 0000000..e34dd7b --- /dev/null +++ b/crates/sync/tests/memory_contract.rs @@ -0,0 +1,9 @@ +//! The backend in memory against the shared contract. + +mod contract; +mod fakes; + +#[tokio::test(start_paused = true)] +async fn the_backend_in_memory_satisfies_the_contract() { + contract::verify(fakes::subject(|memory, _| memory)).await; +}