diff options
Diffstat (limited to 'gleam/test/ocda_write_test.gleam')
| -rw-r--r-- | gleam/test/ocda_write_test.gleam | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/gleam/test/ocda_write_test.gleam b/gleam/test/ocda_write_test.gleam new file mode 100644 index 0000000..433dfdc --- /dev/null +++ b/gleam/test/ocda_write_test.gleam @@ -0,0 +1,233 @@ +import gleam/list +import gleam/string +import gleeunit +import gleeunit/should +import ocda/load_ssp +import ocda/model +import gleam/bit_array +import write/epub +import write/html +import write/text + +pub fn main() { + gleeunit.main() +} + +/// A document small enough to reason about, carrying the things the writer +/// has to get right: a heading, an object with a note inside it, an object +/// with no ocn, and a trailing section with only its own heading. +const minimal = "% SiSU Document Abstraction v2.0 +% Source: example.sst + +@meta { + title.full: An Example + creator.author: A Writer + date.published: 2026-09-25 +} + +@head { +[0] heading :A the_title +.part: frontmatter +| An Example + +} + +@toc { +[0] toc +.indent: 0 4 +| ┥Chapter One┝┤2├ + +} + +@body { +[1] heading :1 c1 +.part: body +| Chapter One + +[2] para +.part: body +.parent: 1 +| A paragraph with a note【1 The note itself.】in it. + +} + +@glossary { +[0] heading :A glossary +.part: backmatter +| Glossary + +} +" + +fn render() -> String { + text.write(load_ssp.parse(minimal)) +} + +/// head, toc, body. Not glossary: it holds its own heading and nothing else, +/// and spine omits a trailing section of one object. +pub fn sections_test() { + let doc = load_ssp.parse(minimal) + text.text_sections(doc) |> should.equal(["head", "toc", "body"]) + render() |> string.contains(contain: "Glossary") |> should.be_false +} + +/// A heading is preceded by a blank line; an object with ocn 0 gets no mark. +pub fn heading_test() { + render() |> string.contains(contain: "\nAn Example\n\n") |> should.be_true + render() |> string.contains(contain: "「0」") |> should.be_false +} + +/// The ocn follows the object, in its own brackets. +pub fn ocn_test() { + render() |> string.contains(contain: "「1」") |> should.be_true + render() |> string.contains(contain: "「2」") |> should.be_true +} + +/// A note is carried inside the object that refers to it: the reference +/// becomes [n] in place and the note is appended after the object. +pub fn note_test() { + let out = render() + out |> string.contains(contain: "a note[1]in it.") |> should.be_true + out |> string.contains(contain: "1. The note itself.") |> should.be_true +} + +/// A toc entry's link becomes its text and, its target being an object, +/// the ocn it points at; one level in use, so no indent. +pub fn toc_test() { + render() + |> string.contains(contain: "\nChapter One ≫2\n") + |> should.be_true +} + +/// The footer names the document and then the program. +pub fn tail_test() { + let out = render() + out |> string.contains(contain: "Title: An Example") |> should.be_true + out |> string.contains(contain: "Author: A Writer") |> should.be_true + out |> string.contains(contain: "Published: 2026-09-25") |> should.be_true + // no copyright or licence in this document, so no line for either + out |> string.contains(contain: "Copyright:") |> should.be_false + out |> string.ends_with("spine\nhttps://sisudoc.org\n") |> should.be_true +} + +/// The writer is given the model and nothing else, so an object it has no +/// case for writes nothing rather than failing. +pub fn unknown_kind_writes_nothing_test() { + let doc = load_ssp.parse(minimal) + let body = model.section(doc, "body") + body |> list.length |> should.equal(2) +} + +// ---- the html writer ----------------------------------------------------- + +fn render_html() -> String { + html.write(load_ssp.parse(minimal)) +} + +/// A level A heading is h1. Spine writes <h0>, which is not an element. +pub fn heading_element_test() { + let out = render_html() + out |> string.contains(contain: "<h1 id=\"the_title\">") |> should.be_true + out |> string.contains(contain: "<h0") |> should.be_false +} + +/// One charset declaration, and no placeholder metadata: a document with no +/// publisher gets no dc.publisher rather than the word "Publisher". +pub fn head_test() { + let out = render_html() + out |> string.contains(contain: "<meta charset=\"utf-8\" />") |> should.be_true + out |> string.contains(contain: "text/plain") |> should.be_false + out |> string.contains(contain: "dc.publisher") |> should.be_false + out |> string.contains(contain: "content=\"year\"") |> should.be_false +} + +/// The object number is a span, not a label, and links to itself. +pub fn ocn_mark_test() { + render_html() + |> string.contains(contain: "<span class=\"ocn\"><a href=\"#2\" class=\"lnkocn\">2</a></span>") + |> should.be_true +} + +/// A note reference points at the gathered note and carries the dpub role. +pub fn noteref_test() { + render_html() + |> string.contains( + contain: "<a id=\"noteref_1\" href=\"#note_1\" role=\"doc-noteref\">1</a>", + ) + |> should.be_true +} + +/// A section carries its dpub-aria role and no invented id. +pub fn section_test() { + let out = render_html() + out |> string.contains(contain: "<section class=\"toc\" role=\"doc-toc\">") |> should.be_true + out |> string.contains(contain: "id=\"section_toc\"") |> should.be_false +} + +/// It is self-contained: the stylesheet is in the file, not linked out of it. +pub fn self_contained_test() { + let out = render_html() + out |> string.contains(contain: "<style>") |> should.be_true + out |> string.contains(contain: "html_scroll.css") |> should.be_false +} + +/// An ampersand in the text becomes &, and a quotation mark in an +/// attribute becomes ". Erlang's re:replace reads "&" in a replacement +/// *string* as the whole match, which turned "&" into the match itself; +/// every replacement here goes through the function form instead. +pub fn escaping_test() { + let doc = + load_ssp.parse( + "% SiSU Document Abstraction v2.0 + +@body { +[1] para +.part: body +| Smith & Jones said \"hello\" <here> + +} +", + ) + let out = html.write(doc) + out |> string.contains(contain: "Smith & Jones") |> should.be_true + out |> string.contains(contain: "<here>") |> should.be_true + out |> string.contains(contain: "\"quot;") |> should.be_false +} + +// ---- the epub writer ----------------------------------------------------- + +/// The container is first and stored: a reading system identifies an epub +/// from the first bytes of the file, so "mimetype" has to be there and +/// uncompressed. +pub fn epub_mimetype_first_test() { + let bytes = epub.write(load_ssp.parse(minimal), "test/nothing.ssp") + // local file header, then the name at offset 30 + let assert Ok(head) = bit_array.slice(bytes, 0, 4) + head |> should.equal(<<0x50, 0x4b, 0x03, 0x04>>) + let assert Ok(name) = bit_array.slice(bytes, 30, 8) + name |> should.equal(<<"mimetype":utf8>>) + let assert Ok(kind) = bit_array.slice(bytes, 38, 20) + kind |> should.equal(<<"application/epub+zip":utf8>>) +} + +/// Every entry has the same date, so that the same document gives the same +/// epub twice. +pub fn epub_is_reproducible_test() { + let doc = load_ssp.parse(minimal) + epub.write(doc, "test/nothing.ssp") + |> should.equal(epub.write(doc, "test/nothing.ssp")) +} + +/// The segments are grouped by name, not by run: a section's objects are not +/// always contiguous, and two zip entries of one name is an epubcheck error. +pub fn epub_segments_are_unique_test() { + let segs = epub.segments(load_ssp.parse(minimal)) + let names = list.map(segs, fn(s) { s.file }) + list.length(names) |> should.equal(list.length(list.unique(names))) +} + +/// A `.ssp` outside a pod cannot give images, and the writer asks before it +/// emits one: an `<img>` whose file is not in the manifest is an error. +pub fn epub_images_test() { + epub.images_named(load_ssp.parse(minimal)) |> should.equal([]) +} |
