summaryrefslogtreecommitdiffhomepage
path: root/gleam/test/ocda_write_test.gleam
diff options
context:
space:
mode:
Diffstat (limited to 'gleam/test/ocda_write_test.gleam')
-rw-r--r--gleam/test/ocda_write_test.gleam233
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 &amp;, and a quotation mark in an
+/// attribute becomes &quot;. Erlang's re:replace reads "&" in a replacement
+/// *string* as the whole match, which turned "&amp;" 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 &amp; Jones") |> should.be_true
+ out |> string.contains(contain: "&lt;here&gt;") |> 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([])
+}