mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f4f198af | ||
|
|
038b0c70ba | ||
|
|
6b5d9c20ee | ||
|
|
dd41a93c9b | ||
|
|
e9f9e75c1d |
No files matched your search
@@ -86,7 +86,7 @@ is a failure — nobody replied, and the identifier may be perfectly good.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `doi` | Crossref | It assigns most scholarly DOIs |
|
| `doi` | Crossref | It assigns most scholarly DOIs |
|
||||||
| `arxiv` | the arXiv | Its own register of preprints |
|
| `arxiv` | the arXiv | Its own register of preprints |
|
||||||
| `isbn` | OpenLibrary's edition record, then its index, then Google Books | Books, without a key |
|
| `isbn` | OpenLibrary's index, then its edition record, then Google Books | Books, without a key |
|
||||||
|
|
||||||
One register is enough for a DOI or a preprint: the body that assigns the
|
One register is enough for a DOI or a preprint: the body that assigns the
|
||||||
identifier is the only one that has it. A book is different. An ISBN is
|
identifier is the only one that has it. A book is different. An ISBN is
|
||||||
@@ -97,6 +97,14 @@ names them. So the registers are asked in turn and the first complete answer
|
|||||||
wins. An incomplete one is kept in case nothing better comes, and a register
|
wins. An incomplete one is kept in case nothing better comes, and a register
|
||||||
being down is a failure only when every register is.
|
being down is a failure only when every register is.
|
||||||
|
|
||||||
|
OpenLibrary's index answers about the work, not the edition: its publishers,
|
||||||
|
languages, years and page counts are every edition's at once, and the first
|
||||||
|
of each is usually another edition's. So for a book it gives only the title,
|
||||||
|
the authors and the subjects, and the `container`, `language`, `year` and
|
||||||
|
`pages` of a record come from the edition record for that ISBN, or from
|
||||||
|
Google Books when the edition record does not say. A field neither gives is
|
||||||
|
left out rather than filled from the work.
|
||||||
|
|
||||||
Google Books is asked last because it answers anonymous callers only until a
|
Google Books is asked last because it answers anonymous callers only until a
|
||||||
daily quota runs out, after which it replies `429` to everyone sharing the
|
daily quota runs out, after which it replies `429` to everyone sharing the
|
||||||
address.
|
address.
|
||||||
@@ -127,7 +135,8 @@ id is asked only when every form of the ISBN asked was answered with no cover; i
|
|||||||
asking by ISBN failed, that failure is the result.
|
asking by ISBN failed, that failure is the result.
|
||||||
|
|
||||||
What comes back is checked before it is written: a JPEG or PNG by its bytes
|
What comes back is checked before it is written: a JPEG or PNG by its bytes
|
||||||
as well as by what the server says, no larger than ten megabytes, reached in at
|
as well as by what the server says, whole to its end marker (`FF D9` for a
|
||||||
|
JPEG, `IEND` for a PNG), no larger than ten megabytes, reached in at
|
||||||
most three redirects, each over HTTPS to `covers.openlibrary.org`,
|
most three redirects, each over HTTPS to `covers.openlibrary.org`,
|
||||||
`archive.org` or a host under `archive.org`, and never to an address by number.
|
`archive.org` or a host under `archive.org`, and never to an address by number.
|
||||||
One request is made at a time, a `429` or `503` is waited out for as long as
|
One request is made at a time, a `429` or `503` is waited out for as long as
|
||||||
|
|||||||
@@ -386,6 +386,23 @@ by ISBN every five minutes from one address"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an image's bytes run to its end marker: `FF D9` for a JPEG, which
|
||||||
|
/// some encoders follow with zero padding, and the `IEND` chunk for a PNG,
|
||||||
|
/// which is its last twelve bytes.
|
||||||
|
fn ends_whole(media_type: &str, bytes: &[u8]) -> bool {
|
||||||
|
match media_type {
|
||||||
|
"image/jpeg" => {
|
||||||
|
let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |at| at + 1);
|
||||||
|
end >= 5 && bytes[..end].ends_with(&[0xFF, 0xD9])
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
bytes.len() >= 8 + 12
|
||||||
|
&& bytes[bytes.len() - 12..].starts_with(&[0, 0, 0, 0])
|
||||||
|
&& &bytes[bytes.len() - 8..bytes.len() - 4] == b"IEND"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read and check a body that is meant to be a cover.
|
/// Read and check a body that is meant to be a cover.
|
||||||
fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> {
|
fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> {
|
||||||
let declared = header(&response, "content-type")
|
let declared = header(&response, "content-type")
|
||||||
@@ -438,6 +455,14 @@ fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result
|
|||||||
"{address}: said {media_type} but is not one"
|
"{address}: said {media_type} but is not one"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// The start of an image is not an image. A body sent with no length,
|
||||||
|
// or a stub, can begin right and stop short; what ends it says it is
|
||||||
|
// whole.
|
||||||
|
if !ends_whole(media_type, &bytes) {
|
||||||
|
return Err(Error::Shape(format!(
|
||||||
|
"{address}: a {media_type} that stops before its end"
|
||||||
|
)));
|
||||||
|
}
|
||||||
// No floor on the size. Open Library's stand-in for a missing cover is a
|
// No floor on the size. Open Library's stand-in for a missing cover is a
|
||||||
// 43-byte, one-pixel GIF, and it serves that only when `default=false`
|
// 43-byte, one-pixel GIF, and it serves that only when `default=false`
|
||||||
// is left off: with it, as every address here has it, a missing cover is
|
// is left off: with it, as every address here has it, a missing cover is
|
||||||
@@ -512,7 +537,12 @@ fn origin(url: &str) -> Option<&str> {
|
|||||||
|
|
||||||
/// Where a `Location` points, read against the address that sent it.
|
/// Where a `Location` points, read against the address that sent it.
|
||||||
fn resolve(from: &str, location: &str) -> String {
|
fn resolve(from: &str, location: &str) -> String {
|
||||||
if location.contains("://") {
|
// Absolute only when the scheme comes first: a relative address can
|
||||||
|
// carry another address in its query.
|
||||||
|
if location
|
||||||
|
.find("://")
|
||||||
|
.is_some_and(|at| !location[..at].contains(['/', '?', '#']))
|
||||||
|
{
|
||||||
return location.to_string();
|
return location.to_string();
|
||||||
}
|
}
|
||||||
if let Some(rest) = location.strip_prefix("//") {
|
if let Some(rest) = location.strip_prefix("//") {
|
||||||
@@ -670,15 +700,19 @@ mod tests {
|
|||||||
.agent()
|
.agent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A JPEG's start, filler, and its end marker, `len` bytes in all.
|
||||||
fn jpeg(len: usize) -> Vec<u8> {
|
fn jpeg(len: usize) -> Vec<u8> {
|
||||||
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
||||||
bytes.resize(len, 0x11);
|
bytes.resize(len - 2, 0x11);
|
||||||
|
bytes.extend([0xFF, 0xD9]);
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A PNG's signature, filler, and its `IEND` chunk, `len` bytes in all.
|
||||||
fn png(len: usize) -> Vec<u8> {
|
fn png(len: usize) -> Vec<u8> {
|
||||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||||
bytes.resize(len, 0x22);
|
bytes.resize(len - 12, 0x22);
|
||||||
|
bytes.extend(b"\0\0\0\0IEND\xAE\x42\x60\x82");
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,6 +1204,38 @@ mod tests {
|
|||||||
assert_eq!(stub.asked(), [ISBN13, ISBN10]);
|
assert_eq!(stub.asked(), [ISBN13, ISBN10]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_magic_number_alone_is_not_a_cover() {
|
||||||
|
let mut cut_jpeg = jpeg(4096);
|
||||||
|
cut_jpeg.truncate(4000);
|
||||||
|
let mut cut_png = png(4096);
|
||||||
|
cut_png.truncate(4000);
|
||||||
|
for (media_type, body) in [
|
||||||
|
("image/jpeg", vec![0xFF, 0xD8, 0xFF]),
|
||||||
|
("image/png", b"\x89PNG\r\n\x1a\n".to_vec()),
|
||||||
|
("image/jpeg", cut_jpeg),
|
||||||
|
("image/png", cut_png),
|
||||||
|
] {
|
||||||
|
let stub = Stub::start(move |_, _| Reply::image(media_type, body.clone()));
|
||||||
|
let found = stub
|
||||||
|
.covers()
|
||||||
|
.fetch(&agent(), "9780306406157", None, CoverSize::Large)
|
||||||
|
.map(|cover| cover.map(|cover| cover.bytes.len()));
|
||||||
|
assert!(
|
||||||
|
matches!(found, Err(Error::Shape(_))),
|
||||||
|
"{media_type}: {found:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jpeg_padded_after_its_end_marker_is_whole() {
|
||||||
|
let mut padded = jpeg(2048);
|
||||||
|
padded.extend([0, 0, 0]);
|
||||||
|
assert!(ends_whole("image/jpeg", &padded));
|
||||||
|
assert!(!ends_whole("image/jpeg", &[0xFF, 0xD8, 0xFF, 0, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_redirect_to_an_address_by_number_is_refused() {
|
fn a_redirect_to_an_address_by_number_is_refused() {
|
||||||
// A listener standing in for a service on the LAN; it only counts.
|
// A listener standing in for a service on the LAN; it only counts.
|
||||||
@@ -1217,6 +1283,15 @@ mod tests {
|
|||||||
assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x");
|
assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x");
|
||||||
assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1");
|
assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1");
|
||||||
assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg");
|
assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg");
|
||||||
|
// An address in a relative one's query does not make it absolute.
|
||||||
|
assert_eq!(
|
||||||
|
resolve(from, "/view_archive.php?from=https://archive.org/x"),
|
||||||
|
"https://a.example/view_archive.php?from=https://archive.org/x"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(from, "x?u=https://c.example/"),
|
||||||
|
"https://a.example/b/isbn/x?u=https://c.example/"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
origin("http://127.0.0.1:8080/a?b"),
|
origin("http://127.0.0.1:8080/a?b"),
|
||||||
Some("http://127.0.0.1:8080")
|
Some("http://127.0.0.1:8080")
|
||||||
|
|||||||
@@ -265,11 +265,10 @@ mod merge_tests {
|
|||||||
let mut index = rec("openlibrary");
|
let mut index = rec("openlibrary");
|
||||||
index.title = Some("The First World War".into());
|
index.title = Some("The First World War".into());
|
||||||
index.authors = vec!["Michael Howard".into()];
|
index.authors = vec!["Michael Howard".into()];
|
||||||
index.year = Some(2007);
|
|
||||||
|
|
||||||
edition.fill_from(index);
|
edition.fill_from(index);
|
||||||
assert_eq!(edition.authors, ["Michael Howard"]);
|
assert_eq!(edition.authors, ["Michael Howard"]);
|
||||||
assert_eq!(edition.year, Some(2007));
|
assert_eq!(edition.language.as_deref(), Some("eng"));
|
||||||
// The fuller title arrived first and is not displaced by the shorter.
|
// The fuller title arrived first and is not displaced by the shorter.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
edition.title.as_deref(),
|
edition.title.as_deref(),
|
||||||
|
|||||||
+119
-33
@@ -17,9 +17,10 @@ pub enum Source {
|
|||||||
Arxiv,
|
Arxiv,
|
||||||
/// OpenLibrary, for books by ISBN.
|
/// OpenLibrary, for books by ISBN.
|
||||||
OpenLibrary,
|
OpenLibrary,
|
||||||
/// OpenLibrary's index, rather than its edition record. Asked when the
|
/// OpenLibrary's index, rather than its edition record. Asked for what is
|
||||||
/// edition record has the book but not who wrote it, which is often: an
|
/// true of the work — the title, the authors by name, the subjects —
|
||||||
/// edition names its authors by key, and the index names them.
|
/// because an edition names its authors by key and the index names them.
|
||||||
|
/// Never for what differs between editions.
|
||||||
OpenLibrarySearch,
|
OpenLibrarySearch,
|
||||||
/// Google Books, for books by ISBN. Asked last, because it answers
|
/// Google Books, for books by ISBN. Asked last, because it answers
|
||||||
/// anonymous callers only until a daily quota runs out.
|
/// anonymous callers only until a daily quota runs out.
|
||||||
@@ -55,14 +56,14 @@ impl Source {
|
|||||||
match kind {
|
match kind {
|
||||||
"doi" => &[Source::Crossref],
|
"doi" => &[Source::Crossref],
|
||||||
"arxiv" => &[Source::Arxiv],
|
"arxiv" => &[Source::Arxiv],
|
||||||
// The index first. Asked for the fields by name it answers with
|
// The index first, for what is true of the work: the title, the
|
||||||
// the title, the authors, the publisher, the language, a page
|
// authors by name, the subjects. Then the edition record, for
|
||||||
// count and the subjects in one request, which is the whole of
|
// what is true of this edition only: the publisher, the language,
|
||||||
// what a catalogue wants; the edition record is then needed only
|
// the year and the length. The index answers about the work, and
|
||||||
// for a subtitle or a publisher the index did not give. Asking it
|
// its publishers, languages and years are every edition's at
|
||||||
// second cost three requests per book where one usually does, and
|
// once, so they are not asked of it; the lookup goes on to the
|
||||||
// at three books in flight that is the difference between two
|
// edition record for them. Google Books last, for whatever the
|
||||||
// days and two hours.
|
// edition record does not say.
|
||||||
"isbn" => &[
|
"isbn" => &[
|
||||||
Source::OpenLibrarySearch,
|
Source::OpenLibrarySearch,
|
||||||
Source::OpenLibrary,
|
Source::OpenLibrary,
|
||||||
@@ -83,15 +84,16 @@ impl Source {
|
|||||||
Source::OpenLibrary => {
|
Source::OpenLibrary => {
|
||||||
format!("https://openlibrary.org/isbn/{}.json", encode(id))
|
format!("https://openlibrary.org/isbn/{}.json", encode(id))
|
||||||
}
|
}
|
||||||
// `fields` or the index answers with a handful of defaults and
|
// Only what is true of the work. Not `publisher`, `language`,
|
||||||
// keeps the rest, which is how a language it holds went unread.
|
// `first_publish_year` or `number_of_pages_median`: those are every
|
||||||
// Not `cover_i`: that is the cover chosen for the whole work, and
|
// edition's at once, and the first of each is usually another
|
||||||
// on a work with hundreds of editions it is usually another
|
// edition's (the Penguin *War and Peace* came back Spanish, from
|
||||||
// edition's, in another translation or language.
|
// W.W. Norton, in 1845). Not `cover_i`: that is the cover chosen
|
||||||
|
// for the whole work, often another edition's in another
|
||||||
|
// translation or language.
|
||||||
Source::OpenLibrarySearch => format!(
|
Source::OpenLibrarySearch => format!(
|
||||||
"https://openlibrary.org/search.json?q=isbn:{}\
|
"https://openlibrary.org/search.json?q=isbn:{}\
|
||||||
&fields=title,subtitle,author_name,first_publish_year,publisher,language,subject,\
|
&fields=title,subtitle,author_name,subject&limit=1",
|
||||||
number_of_pages_median&limit=1",
|
|
||||||
encode(id)
|
encode(id)
|
||||||
),
|
),
|
||||||
Source::GoogleBooks => format!(
|
Source::GoogleBooks => format!(
|
||||||
@@ -295,6 +297,8 @@ struct SearchReply {
|
|||||||
docs: Vec<SearchDoc>,
|
docs: Vec<SearchDoc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the index says about a work. Only the fields true of every edition
|
||||||
|
/// are read, so an answer that carries the others anyway cannot leak them.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SearchDoc {
|
struct SearchDoc {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -304,15 +308,7 @@ struct SearchDoc {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
author_name: Vec<String>,
|
author_name: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
first_publish_year: Option<i64>,
|
|
||||||
#[serde(default)]
|
|
||||||
publisher: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
language: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
subject: Vec<String>,
|
subject: Vec<String>,
|
||||||
#[serde(rename = "number_of_pages_median", default)]
|
|
||||||
pages: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many of a work's subject headings are worth carrying.
|
/// How many of a work's subject headings are worth carrying.
|
||||||
@@ -324,6 +320,11 @@ pub(crate) const SUBJECT_LIMIT: usize = 12;
|
|||||||
|
|
||||||
/// A search that matched nothing answers with no documents rather than a
|
/// A search that matched nothing answers with no documents rather than a
|
||||||
/// status, so an empty reply is the absence and not a failure.
|
/// status, so an empty reply is the absence and not a failure.
|
||||||
|
///
|
||||||
|
/// The record says what is true of the work and nothing that belongs to one
|
||||||
|
/// edition: no publisher, language, year or page count. Those come from the
|
||||||
|
/// edition record, which [`crate::ask::lookup`] asks next because a record
|
||||||
|
/// without them is never complete.
|
||||||
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
||||||
let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
||||||
let Some(doc) = reply.docs.into_iter().next() else {
|
let Some(doc) = reply.docs.into_iter().next() else {
|
||||||
@@ -336,14 +337,10 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
|
|||||||
(title, _) => title,
|
(title, _) => title,
|
||||||
};
|
};
|
||||||
record.authors = doc.author_name;
|
record.authors = doc.author_name;
|
||||||
record.year = doc.first_publish_year;
|
|
||||||
record.container = doc.publisher.into_iter().next();
|
|
||||||
record.language = doc.language.into_iter().next();
|
|
||||||
// The index lists every subject any edition of the work carries, which
|
// The index lists every subject any edition of the work carries, which
|
||||||
// runs to hundreds on a well-known book. A shelf needs a handful.
|
// runs to hundreds on a well-known book. A shelf needs a handful.
|
||||||
record.subjects = doc.subject;
|
record.subjects = doc.subject;
|
||||||
record.subjects.truncate(SUBJECT_LIMIT);
|
record.subjects.truncate(SUBJECT_LIMIT);
|
||||||
record.pages = doc.pages;
|
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,9 +472,8 @@ mod tests {
|
|||||||
fn each_kind_goes_to_the_body_that_assigns_it() {
|
fn each_kind_goes_to_the_body_that_assigns_it() {
|
||||||
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
|
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
|
||||||
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
|
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
|
||||||
// The index, not the edition record: it answers with the whole of
|
// The index first, for the authors by name; the edition record names
|
||||||
// what a catalogue wants in one request, where the edition record
|
// them by key, and is asked next for what only the edition knows.
|
||||||
// takes up to twenty-eight seconds and names its authors by key.
|
|
||||||
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch));
|
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch));
|
||||||
// No register answers about an ISSN without a key.
|
// No register answers about an ISSN without a key.
|
||||||
assert_eq!(Source::for_kind("issn"), None);
|
assert_eq!(Source::for_kind("issn"), None);
|
||||||
@@ -599,6 +595,96 @@ mod tests {
|
|||||||
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
|
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the index said for ISBN 9780140447934, the Penguin Classics *War
|
||||||
|
/// and Peace*, on 2026-11-13, asked for the fields the lookup asked for
|
||||||
|
/// before #6: the publishers, languages and first year of every edition
|
||||||
|
/// of the work.
|
||||||
|
const WAR_AND_PEACE_INDEX: &str = r#"{"docs":[{"title":"War and Peace",
|
||||||
|
"author_name":["Leo Tolstoy"],"first_publish_year":1845,
|
||||||
|
"publisher":["W.W. Norton","IndyPublish.com","Canterbury Classics"],
|
||||||
|
"language":["spa","gre","dut","eng"],"subject":["Fiction"],
|
||||||
|
"number_of_pages_median":1296}]}"#;
|
||||||
|
|
||||||
|
/// The edition record for the same ISBN, `/isbn/9780140447934.json`, with
|
||||||
|
/// the English language its `languages` field should name.
|
||||||
|
const WAR_AND_PEACE_EDITION: &str = r#"{"title":"War and Peace (Penguin Classics)",
|
||||||
|
"publishers":["Penguin Books"],"publish_date":"September 27, 2007",
|
||||||
|
"languages":[{"key":"/languages/eng"}],"number_of_pages":1440}"#;
|
||||||
|
|
||||||
|
/// Index first, then the edition record, merged as `ask::lookup` merges.
|
||||||
|
fn looked_up(edition: &str) -> Record {
|
||||||
|
let mut kept =
|
||||||
|
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||||
|
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_index_says_nothing_only_an_edition_can() {
|
||||||
|
let record =
|
||||||
|
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||||
|
// What is true of the work is kept.
|
||||||
|
assert_eq!(record.title.as_deref(), Some("War and Peace"));
|
||||||
|
assert_eq!(record.authors, ["Leo Tolstoy"]);
|
||||||
|
// What belongs to one edition is not taken from all of them.
|
||||||
|
assert_eq!(record.language, None);
|
||||||
|
assert_eq!(record.container, None);
|
||||||
|
assert_eq!(record.year, None);
|
||||||
|
assert_eq!(record.pages, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lookup_by_isbn_gives_the_editions_language_publisher_and_year() {
|
||||||
|
let record = looked_up(WAR_AND_PEACE_EDITION);
|
||||||
|
assert_eq!(record.language.as_deref(), Some("eng"));
|
||||||
|
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
|
||||||
|
assert_eq!(record.year, Some(2007));
|
||||||
|
assert_eq!(record.pages, Some(1440));
|
||||||
|
assert_eq!(record.authors, ["Leo Tolstoy"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_the_edition_does_not_say_is_missing_rather_than_the_works() {
|
||||||
|
// The real edition record today names no language at all.
|
||||||
|
let record = looked_up(
|
||||||
|
r#"{"title":"War and Peace (Penguin Classics)","publishers":["Penguin Books"],
|
||||||
|
"publish_date":"September 27, 2007","number_of_pages":1440}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(record.language, None);
|
||||||
|
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
|
||||||
|
assert_eq!(record.year, Some(2007));
|
||||||
|
// Nor a date: the work's first year is not this edition's.
|
||||||
|
let record = looked_up(r#"{"title":"War and Peace (Penguin Classics)"}"#);
|
||||||
|
assert_eq!(record.year, None);
|
||||||
|
assert_eq!(record.container, None);
|
||||||
|
assert_eq!(record.pages, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_index_alone_never_ends_a_lookup() {
|
||||||
|
// `lookup` stops at the first complete record; the index must not be
|
||||||
|
// one, or the edition record is never asked.
|
||||||
|
let record =
|
||||||
|
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
|
||||||
|
assert!(!record.complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_index_is_not_asked_for_what_its_editions_differ_in() {
|
||||||
|
let url = Source::OpenLibrarySearch.url("9780140447934");
|
||||||
|
for field in [
|
||||||
|
"publisher",
|
||||||
|
"language",
|
||||||
|
"first_publish_year",
|
||||||
|
"number_of_pages_median",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!url.split(['=', ',', '&']).any(|f| f == field),
|
||||||
|
"{field} in {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_answer_that_is_not_what_it_should_be_says_so() {
|
fn an_answer_that_is_not_what_it_should_be_says_so() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
Reference in new issue
Block a user