diff --git a/classes/content_facts_test.go b/classes/content_facts_test.go
new file mode 100644
index 0000000..d03e320
--- /dev/null
+++ b/classes/content_facts_test.go
@@ -0,0 +1,69 @@
+package classes
+
+import (
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// TestParseContentFacts pins what a body says about itself: images with
+// alt, decorative (alt present and empty) and size attributes (negative
+// and invalid sizes 0), an img without src skipped; YouTube embeds of the
+// editor's figure; the heading outline with collapsed text, inline tags
+// joined, block children spaced, a no-break space collapsed like any
+// other, a heading inside a heading read as part
+// of the outer one.
+func TestParseContentFacts(t *testing.T) {
+ f := parseContentFacts(`
Lwia grzywa
+
+
+
Dawkowanie i forma
Outer
inner
tail
`)
+ wantImages := []pact.ContentImage{
+ {URL: "/m/a.jpg", Alt: "Lion's mane", Width: 800},
+ {URL: "/m/line.png", Decorative: true},
+ {URL: "/m/b.jpg"},
+ }
+ if len(f.images) != len(wantImages) {
+ t.Fatalf("images = %+v", f.images)
+ }
+ for i, w := range wantImages {
+ if f.images[i] != w {
+ t.Errorf("image %d = %+v, want %+v", i, f.images[i], w)
+ }
+ }
+ if len(f.videos) != 1 || f.videos[0] != (pact.ContentVideo{Provider: "youtube", ID: "abc123"}) {
+ t.Fatalf("videos = %+v", f.videos)
+ }
+ wantHeadings := []pact.ContentHeading{{Level: 2, Text: "Lwia grzywa"}, {Level: 3, Text: "Dawkowanie i forma"}, {Level: 4, Text: "Outer inner tail"}, {Level: 2, Text: ""}}
+ if len(f.headings) != len(wantHeadings) {
+ t.Fatalf("headings = %+v", f.headings)
+ }
+ for i, w := range wantHeadings {
+ if f.headings[i] != w {
+ t.Errorf("heading %d = %+v, want %+v", i, f.headings[i], w)
+ }
+ }
+}
+
+// TestParseContentFactsEmpty pins an empty body and the category pillar
+// text read the same way (headings only).
+func TestParseContentFactsEmpty(t *testing.T) {
+ if f := parseContentFacts(" "); f.images != nil || f.videos != nil || f.headings != nil {
+ t.Fatalf("blank = %+v", f)
+ }
+ pillar := parseContentFacts("
Intro
Gatunki
Skipped
")
+ if len(pillar.headings) != 2 || pillar.headings[1].Level != 4 {
+ t.Fatalf("pillar = %+v", pillar.headings)
+ }
+}
+
+// TestTopHeading pins the record's highest heading level: 2 while the
+// H1 setting turns body H1s into H2s, 0 (any) otherwise.
+func TestTopHeading(t *testing.T) {
+ if topHeading(true) != 2 || topHeading(false) != 0 {
+ t.Fatal("topHeading is wrong")
+ }
+ if toolUpdatePost != "journal_update_post" || toolFixHeadings != "journal_fix_headings" || toolSetImageAlt != "journal_set_image_alt" {
+ t.Fatal("the descriptor's tool names changed")
+ }
+}
diff --git a/classes/headings_test.go b/classes/headings_test.go
index 0669d6d..cb3f05e 100644
--- a/classes/headings_test.go
+++ b/classes/headings_test.go
@@ -28,3 +28,41 @@ func TestConvertH1(t *testing.T) {
}
}
}
+
+// TestConvertContentH1 pins the body pair: content_html's count when its
+// HTML had H1s, content's otherwise (a markdown body's H1 is only in its
+// rendered HTML), both converted.
+func TestConvertContentH1(t *testing.T) {
+ c, h, n := ConvertContentH1("# Title\n\ntext", "
Title
text
")
+ if c != "# Title\n\ntext" || h != "
Title
text
" || n != 1 {
+ t.Fatalf("markdown body = %q %q %d", c, h, n)
+ }
+ c, h, n = ConvertContentH1("
a
b
", "")
+ if c != "
a
b
" || h != "" || n != 2 {
+ t.Fatalf("HTML body without rendered HTML = %q %q %d", c, h, n)
+ }
+ if _, _, n := ConvertContentH1("
x
", "
x
"); n != 0 {
+ t.Fatal("no H1, nothing converted")
+ }
+ if CountH1(`
a
b
c
`) != 2 || CountH1("") != 0 {
+ t.Fatal("CountH1 is wrong")
+ }
+}
+
+// TestConvertTranslationH1 pins a locale's translated attributes,
+// changed in place: content and content_html converted, other attributes
+// and values that are not strings left alone (a category's pillar text
+// takes the same path as a post body).
+func TestConvertTranslationH1(t *testing.T) {
+ attrs := map[string]any{"title": "
not content
", "content": "
Pillar
x
", "content_html": 5}
+ if n := ConvertTranslationH1(attrs); n != 1 || attrs["content"] != "
"}
+ if n := ConvertTranslationH1(both); n != 1 || both["content_html"] != "
T
" || both["content"] != "# T" {
+ t.Fatalf("both = %v n=%d", both, n)
+ }
+ if ConvertTranslationH1(nil) != 0 || ConvertTranslationH1(map[string]any{"title": "x"}) != 0 {
+ t.Fatal("nothing to convert")
+ }
+}
diff --git a/classes/image_alt_test.go b/classes/image_alt_test.go
index 88d50e1..6b61fb3 100644
--- a/classes/image_alt_test.go
+++ b/classes/image_alt_test.go
@@ -20,3 +20,28 @@ func TestSetImageAlt(t *testing.T) {
t.Fatalf("missing = %q, %d", out, n)
}
}
+
+// TestSetImageAltEdges pins the edges: every matching img is rewritten
+// and counted (the first one's old alt answered), repeated alt attributes
+// collapse to one, a src given escaped does not match the unescaped one,
+// an empty src and a body without img change nothing, and text that
+// looks like an img inside a comment or script is not touched.
+func TestSetImageAltEdges(t *testing.T) {
+ doc := `
t
`
+ out, n, old := SetImageAlt(doc, "a.jpg", "new")
+ if n != 2 || old == nil || *old != "one" {
+ t.Fatalf("n=%d old=%v", n, old)
+ }
+ want := `
t
`
+ if out != want {
+ t.Fatalf("out = %q\nwant %q", out, want)
+ }
+ if out, n, _ := SetImageAlt(``, "a.jpg?x=1&y=2", "z"); n != 0 || out != `` {
+ t.Fatalf("escaped src matched: %q %d", out, n)
+ }
+ for _, tc := range []struct{ html, src string }{{``, ""}, {`
no images
`, "a.jpg"}, {"", "a.jpg"}} {
+ if out, n, old := SetImageAlt(tc.html, tc.src, "x"); out != tc.html || n != 0 || old != nil {
+ t.Errorf("SetImageAlt(%q, %q) = %q %d %v", tc.html, tc.src, out, n, old)
+ }
+ }
+}
diff --git a/classes/related_test.go b/classes/related_test.go
new file mode 100644
index 0000000..69a6acd
--- /dev/null
+++ b/classes/related_test.go
@@ -0,0 +1,47 @@
+package classes
+
+import (
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/compass"
+)
+
+// TestReadRelatedSettings pins the related-posts config: the category mode
+// and 3 by default; "scored" in any case turns scoring on; the count
+// clamped to 3..6, 0 or a non-number falling back to 3. The scoring and
+// its ties by the newest post are TestJournalAPIMatrix's on the database.
+func TestReadRelatedSettings(t *testing.T) {
+ if s := ReadRelatedSettings(nil); s.Mode != RelatedModeCategory || s.Count != 3 || s.Scored() {
+ t.Fatalf("nil config = %+v", s)
+ }
+ for _, tc := range []struct {
+ mode string
+ count any
+ want RelatedSettings
+ }{
+ {"", nil, RelatedSettings{RelatedModeCategory, 3}},
+ {" Scored ", 5, RelatedSettings{RelatedModeScored, 5}},
+ {"scored", 1, RelatedSettings{RelatedModeScored, 3}},
+ {"scored", 99, RelatedSettings{RelatedModeScored, 6}},
+ {"category", 4, RelatedSettings{RelatedModeCategory, 4}},
+ {"tags", 0, RelatedSettings{RelatedModeCategory, 3}},
+ {"scored", "many", RelatedSettings{RelatedModeScored, 3}},
+ } {
+ cfg, err := compass.Open(compass.Options{Dir: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tc.mode != "" {
+ _ = cfg.Set(RelatedModeKey, tc.mode)
+ }
+ if tc.count != nil {
+ _ = cfg.Set(RelatedCountKey, tc.count)
+ }
+ if got := ReadRelatedSettings(cfg); got != tc.want {
+ t.Errorf("mode %q count %v = %+v, want %+v", tc.mode, tc.count, got, tc.want)
+ }
+ }
+ if relatedTagPoints != 2 || relatedCategoryPoints != 1 {
+ t.Fatal("a shared tag is 2 points, a shared category 1")
+ }
+}