test(16.2-06): pin the imported-post resave contract
- element.test.ts: an untouched value survives Source tab, tab buttons, preview and reattach byte for byte with no summer-change - TestBlogEditorLoad: HTML verbatim, markdown as content_html, FormatHTML fallback, nil and empty values - TestPostsAdminResaveKeepsHTML (Docker host): GET then PUT the served values for an HTML and a markdown post, exact bytes, no notices, then a clean edited save in both locales
This commit is contained in:
138
classes/blog_editor_test.go
Normal file
138
classes/blog_editor_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Unit tests of the Blog Editor field types (Phase 16.2 plan 06).
|
||||
|
||||
// blogOptions are the post content field's options.
|
||||
var blogOptions = map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"}
|
||||
|
||||
// noTranslated is a Translated reader that fails the test when called.
|
||||
func noTranslated(t *testing.T) func(string) (string, bool, error) {
|
||||
return func(attribute string) (string, bool, error) {
|
||||
t.Errorf("Translated(%q) called for HTML content", attribute)
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// translatedReader serves one attribute and records what was asked.
|
||||
func translatedReader(asked *[]string, value string, found bool, err error) func(string) (string, bool, error) {
|
||||
return func(attribute string) (string, bool, error) {
|
||||
*asked = append(*asked, attribute)
|
||||
return value, found, err
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlogEditorLoad(t *testing.T) {
|
||||
const markdown = "## Nagłówek\n\nTekst z *kursywą*."
|
||||
rendered, err := FormatHTML(markdown)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rendered == markdown || rendered == "" {
|
||||
t.Fatalf("FormatHTML(%q) = %q", markdown, rendered)
|
||||
}
|
||||
html := "<p>Grzyby</p>"
|
||||
empty := ""
|
||||
|
||||
t.Run("HTML content is served verbatim", func(t *testing.T) {
|
||||
for _, stored := range []string{
|
||||
"<p>Grzyby rosną.</p>\n<footer class=\"author-footer\"><p>Autor</p></footer>\n",
|
||||
"\n \t<p>leading white space</p>",
|
||||
"<!-- imported --><p>leading comment</p>",
|
||||
"\n<!-- imported -->\n<p>both</p>\n",
|
||||
"<style>.x{}</style><p>unsafe markup is served too; Decode strips it on save</p>",
|
||||
} {
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||
Options: blogOptions, Locale: "pl", Default: true, Value: stored, Translated: noTranslated(t),
|
||||
})
|
||||
if err != nil || got != stored {
|
||||
t.Fatalf("Load(%q) = %q, %v; want the stored bytes", stored, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown content is served as that locale's content_html", func(t *testing.T) {
|
||||
var asked []string
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||
Options: blogOptions, Locale: "en", Value: markdown,
|
||||
Translated: translatedReader(&asked, "<h2>Imported</h2>\n", true, nil),
|
||||
})
|
||||
if err != nil || got != "<h2>Imported</h2>\n" {
|
||||
t.Fatalf("Load = %q, %v", got, err)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "content_html" {
|
||||
t.Fatalf("asked %v, want [content_html]", asked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty or missing content_html falls back to FormatHTML", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
translated func(string) (string, bool, error)
|
||||
}{
|
||||
{"empty", translatedReader(new([]string), "", true, nil)},
|
||||
{"missing", translatedReader(new([]string), "", false, nil)},
|
||||
{"no writer", nil},
|
||||
} {
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||
Options: blogOptions, Locale: "pl", Default: true, Value: markdown, Translated: tc.translated,
|
||||
})
|
||||
if err != nil || got != rendered {
|
||||
t.Fatalf("%s: Load = %q, %v; want %q", tc.name, got, err, rendered)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a Translated error is the Load error", func(t *testing.T) {
|
||||
boom := errors.New("read failed")
|
||||
_, err := loadBlogHTML(context.Background(), pact.FormFieldValue{
|
||||
Options: blogOptions, Value: markdown, Translated: translatedReader(new([]string), "", false, boom),
|
||||
})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("err = %v, want %v", err, boom)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil and empty values give the empty string", func(t *testing.T) {
|
||||
for _, value := range []any{nil, (*string)(nil), &empty, "", " \n"} {
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: noTranslated(t)})
|
||||
want := storedText(value)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a *string is dereferenced", func(t *testing.T) {
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Value: &html})
|
||||
if err != nil || got != html {
|
||||
t.Fatalf("Load = %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without htmlColumn every value is served as stored", func(t *testing.T) {
|
||||
got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: map[string]any{"uploadFolder": "categories"}, Value: markdown, Translated: noTranslated(t)})
|
||||
if err != nil || got != markdown {
|
||||
t.Fatalf("Load = %q, %v; want the stored markdown", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the registered types use this Load", func(t *testing.T) {
|
||||
for _, typ := range BlogEditorTypes(nil) {
|
||||
if typ.Load == nil {
|
||||
t.Fatalf("%s has no Load", typ.Name)
|
||||
}
|
||||
got, err := typ.Load(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: "\n<!-- c --><p>x</p>"})
|
||||
if err != nil || got != "\n<!-- c --><p>x</p>" {
|
||||
t.Fatalf("%s: Load = %q, %v", typ.Name, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -104,9 +104,23 @@ func TestPostsAdminCreateSmoke(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPostsAdminResaveKeepsHTML is the acceptance test of the todo
|
||||
// journal-imported-html-admin-resave (D-03): an admin save of a post whose
|
||||
// content is HTML, as imported posts are, keeps that HTML in content_html
|
||||
// instead of goldmark's raw-HTML placeholder.
|
||||
// journal-imported-html-admin-resave (Phase 16.2 D-03, D-07) on the real
|
||||
// admin API: the administrator opens a post and saves it without touching
|
||||
// the editor. The test reads the form values the API serves with GET and
|
||||
// PUTs them back unchanged, as the admin SPA does.
|
||||
//
|
||||
// - An HTML post keeps content and content_html byte for byte, gains no
|
||||
// translated content for its untouched empty Polish value, and the
|
||||
// response has no meta.notices key.
|
||||
// - A markdown post (content markdown, content_html rendered at import, as
|
||||
// Płytarium-era imports are) is served as content_html (D-07) and keeps
|
||||
// its markdown content and its content_html byte for byte in both
|
||||
// locales, even though its content_html holds markup the policy would
|
||||
// strip: an untouched value is never decoded.
|
||||
// - An edited save of clean HTML in both locales stores equal content and
|
||||
// content_html per locale and reports nothing.
|
||||
//
|
||||
// The harness's default locale is en (host columns); pl is the translation.
|
||||
func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
@@ -114,41 +128,227 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) {
|
||||
controllers.PermissionAccessPublish,
|
||||
controllers.PermissionAccessOtherPosts,
|
||||
)
|
||||
const content = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>"
|
||||
body := map[string]any{
|
||||
"title": map[string]any{"en": "Imported", "pl": "Zaimportowany"},
|
||||
"slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"},
|
||||
"content": map[string]any{"en": content, "pl": content},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
}
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, body)
|
||||
|
||||
t.Run("HTML post", func(t *testing.T) {
|
||||
const content = "<h2>Wstęp</h2>\n" +
|
||||
"<p>Grzyby rosną w lesie.</p>\n" +
|
||||
"<p>Druga <b>część</b> z <a href=\"https://example.test/zrodlo\">linkiem</a>.</p>\n" +
|
||||
"<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n"
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Imported", "pl": "Zaimportowany"},
|
||||
"slug": map[string]any{"en": "imported-html", "pl": "zaimportowany-html"},
|
||||
"content": map[string]any{"en": content},
|
||||
"excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"},
|
||||
})
|
||||
if notices := adminNotices(t, rec); notices != nil {
|
||||
t.Fatalf("clean create carries meta.notices: %s", rec.Body.String())
|
||||
}
|
||||
id := adminCreatedID(t, rec)
|
||||
assertHostContent(t, env, id, "create", content, content)
|
||||
before := translatedAttrs(t, env, id, "pl")
|
||||
rowsBefore := translatedRows(t, env, id)
|
||||
|
||||
served := servedForm(t, env, token, id)
|
||||
if got := servedLocales(t, served, "content"); got["en"] != content || got["pl"] != "" {
|
||||
t.Fatalf("served content = %#v, want en as stored and an empty pl", got)
|
||||
}
|
||||
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
||||
if _, ok := adminMeta(t, put)["notices"]; ok {
|
||||
t.Fatalf("untouched save carries meta.notices: %s", put.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "untouched re-save", content, content)
|
||||
after := translatedAttrs(t, env, id, "pl")
|
||||
for _, key := range []string{"content", "content_html"} {
|
||||
old, had := before[key]
|
||||
now, has := after[key]
|
||||
if had != has || old != now {
|
||||
t.Fatalf("untouched re-save changed pl %s: before %#v (%v), after %#v (%v)", key, old, had, now, has)
|
||||
}
|
||||
if has {
|
||||
t.Fatalf("pl %s written for an untouched empty value: %#v", key, now)
|
||||
}
|
||||
}
|
||||
if rows := translatedRows(t, env, id); rows != rowsBefore {
|
||||
t.Fatalf("untouched re-save added translation rows: %d -> %d", rowsBefore, rows)
|
||||
}
|
||||
|
||||
const editedEN = "<p>Edited <em>English</em> text.</p>\n<ol start=\"3\"><li>three</li></ol>\n"
|
||||
const editedPL = "<p>Poprawiony <strong>polski</strong> tekst.</p>\n<p><img src=\"https://cdn.example.test/a.jpg\" alt=\"grzyb\"></p>\n"
|
||||
editBody := servedBody(served, "title", "slug", "excerpt")
|
||||
editBody["content"] = map[string]any{"en": editedEN, "pl": editedPL}
|
||||
edit := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, editBody)
|
||||
if _, ok := adminMeta(t, edit)["notices"]; ok {
|
||||
t.Fatalf("clean edited save carries meta.notices: %s", edit.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "edited save", editedEN, editedEN)
|
||||
pl := translatedAttrs(t, env, id, "pl")
|
||||
if pl["content"] != editedPL || pl["content_html"] != pl["content"] {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], editedPL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown post", func(t *testing.T) {
|
||||
const markdownEN = "## Heading\n\nText with *italics* and **bold**.\n\n- one\n- two\n"
|
||||
const markdownPL = "## Nagłówek\n\nTekst z *kursywą* i **pogrubieniem**.\n\n- jeden\n- dwa\n"
|
||||
// The content_html an import stored: rendered markdown plus the raw
|
||||
// author footer and its style block, which the policy would strip.
|
||||
const footer = "<style>.author-footer{color:#555}</style>\n<footer class=\"author-footer\" itemscope=\"\" itemtype=\"https://schema.org/Person\"><p>Autor</p></footer>\n"
|
||||
const renderedEN = "<h2>Heading</h2>\n<p>Text with <em>italics</em> and <strong>bold</strong>.</p>\n<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n" + footer
|
||||
const renderedPL = "<h2>Nagłówek</h2>\n<p>Tekst z <em>kursywą</em> i <strong>pogrubieniem</strong>.</p>\n<ul>\n<li>jeden</li>\n<li>dwa</li>\n</ul>\n" + footer
|
||||
|
||||
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||
"title": map[string]any{"en": "Markdown", "pl": "Markdown PL"},
|
||||
"slug": map[string]any{"en": "imported-markdown", "pl": "zaimportowany-markdown"},
|
||||
"content": map[string]any{"en": markdownEN, "pl": markdownPL},
|
||||
})
|
||||
id := adminCreatedID(t, rec)
|
||||
assertHostContent(t, env, id, "markdown create", markdownEN, markdownEN)
|
||||
// What an import leaves behind: content_html rendered from markdown.
|
||||
if err := env.gdb.Model(&models.Post{}).Where("id = ?", id).Update("content_html", renderedEN).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setTranslatedAttr(t, env, id, "pl", "content_html", renderedPL)
|
||||
|
||||
served := servedForm(t, env, token, id)
|
||||
if got := servedLocales(t, served, "content"); got["en"] != renderedEN || got["pl"] != renderedPL {
|
||||
t.Fatalf("served content = %#v, want each locale's content_html (D-07)", got)
|
||||
}
|
||||
put := env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, servedBody(served, "title", "slug", "content", "excerpt"))
|
||||
if _, ok := adminMeta(t, put)["notices"]; ok {
|
||||
t.Fatalf("untouched markdown save carries meta.notices: %s", put.Body.String())
|
||||
}
|
||||
assertHostContent(t, env, id, "untouched markdown re-save", markdownEN, renderedEN)
|
||||
pl := translatedAttrs(t, env, id, "pl")
|
||||
if pl["content"] != markdownPL || pl["content_html"] != renderedPL {
|
||||
t.Fatalf("pl content = %#v, content_html = %#v, want the markdown and the imported HTML unchanged", pl["content"], pl["content_html"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// adminCreatedID returns the id of a create response's record.
|
||||
func adminCreatedID(t *testing.T, rec *httptest.ResponseRecorder) uint {
|
||||
t.Helper()
|
||||
var created struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &created); err != nil || created.ID == 0 {
|
||||
t.Fatalf("create data %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return created.ID
|
||||
}
|
||||
|
||||
assertHTML := func(stage string) {
|
||||
t.Helper()
|
||||
var stored models.Post
|
||||
if err := env.gdb.First(&stored, created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
||||
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
||||
}
|
||||
if stored.ContentHTML != content {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, content)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
||||
// adminMeta returns a response's meta object by key.
|
||||
func adminMeta(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("admin body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Meta
|
||||
}
|
||||
|
||||
// assertHostContent compares the host columns byte for byte (no trimming).
|
||||
func assertHostContent(t *testing.T, env *adminEnv, id uint, stage, content, contentHTML string) {
|
||||
t.Helper()
|
||||
var stored models.Post
|
||||
if err := env.gdb.First(&stored, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stored.ContentHTML, "raw HTML omitted") {
|
||||
t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML)
|
||||
}
|
||||
if stored.Content != content {
|
||||
t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content)
|
||||
}
|
||||
if stored.ContentHTML != contentHTML {
|
||||
t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, contentHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// servedForm returns the record data the admin API serves the form with.
|
||||
func servedForm(t *testing.T, env *adminEnv, token string, id uint) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id)), token, nil)
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &data); err != nil {
|
||||
t.Fatalf("show data %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// servedLocales decodes one served locale map.
|
||||
func servedLocales(t *testing.T, served map[string]json.RawMessage, field string) map[string]string {
|
||||
t.Helper()
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(served[field], &out); err != nil {
|
||||
t.Fatalf("served %s %s: %v", field, served[field], err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// servedBody is a save body of the named served fields, unchanged.
|
||||
func servedBody(served map[string]json.RawMessage, fields ...string) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, field := range fields {
|
||||
if raw, ok := served[field]; ok {
|
||||
out[field] = raw
|
||||
}
|
||||
}
|
||||
assertHTML("create")
|
||||
return out
|
||||
}
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, created.ID)), token, body)
|
||||
assertHTML("re-save")
|
||||
// translatedAttrs returns a post's attribute_data in locale, empty when it
|
||||
// has no row.
|
||||
func translatedAttrs(t *testing.T, env *adminEnv, id uint, locale string) map[string]any {
|
||||
t.Helper()
|
||||
var raw *string
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Select("attribute_data").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
||||
Limit(1).
|
||||
Scan(&raw).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if raw == nil || *raw == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*raw), &out); err != nil {
|
||||
t.Fatalf("attribute_data %q: %v", *raw, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// translatedRows counts a post's translation rows.
|
||||
func translatedRows(t *testing.T, env *adminEnv, id uint) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := env.gdb.Table("golem15_translate_attributes").
|
||||
Where("model_id = ? AND model_type = ?", fmt.Sprint(id), models.Post{}.MorphName()).
|
||||
Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// setTranslatedAttr sets one key of a post's attribute_data in locale, as an
|
||||
// import does.
|
||||
func setTranslatedAttr(t *testing.T, env *adminEnv, id uint, locale, key, value string) {
|
||||
t.Helper()
|
||||
attrs := translatedAttrs(t, env, id, locale)
|
||||
attrs[key] = value
|
||||
raw, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := env.gdb.Table("golem15_translate_attributes").
|
||||
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(id), models.Post{}.MorphName(), locale).
|
||||
Update("attribute_data", string(raw))
|
||||
if res.Error != nil || res.RowsAffected != 1 {
|
||||
t.Fatalf("set %s %s: rows=%d err=%v", locale, key, res.RowsAffected, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and
|
||||
|
||||
110
tests/blogeditor/element.test.ts
Normal file
110
tests/blogeditor/element.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// The imported-post resave contract at the element layer (Phase 16.2 D-03,
|
||||
// D-07; todo journal-imported-html-admin-resave): a value the administrator
|
||||
// does not touch comes back as the exact served string, even after the
|
||||
// Source tab and the preview were opened and closed, and the element
|
||||
// announces nothing.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import { button, click, mount } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
// What the server serves for a markdown post (D-07): its content_html, as
|
||||
// goldmark rendered it at import, with the raw author footer and a trailing
|
||||
// newline.
|
||||
const markdownContentHTML =
|
||||
'<h2>Nagłówek</h2>\n' +
|
||||
'<p>Tekst z <em>kursywą</em> i <strong>pogrubieniem</strong>.</p>\n' +
|
||||
'<ul>\n<li>jeden</li>\n<li>dwa</li>\n</ul>\n' +
|
||||
'<blockquote>\n<p>Cytat</p>\n</blockquote>\n' +
|
||||
'<p><a href="https://example.test/zrodlo">źródło</a></p>\n' +
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>\n'
|
||||
|
||||
const samples: [string, string][] = [
|
||||
['the imported HTML post', fixture],
|
||||
['a markdown post served as content_html', markdownContentHTML],
|
||||
['HTML with leading white space and a comment', '\n <!-- imported -->\n<p>a b</p>\n'],
|
||||
['an empty value', ''],
|
||||
]
|
||||
|
||||
function tab(el: HTMLElement, name: 'wysiwyg' | 'source'): HTMLButtonElement {
|
||||
const found = el.querySelector<HTMLButtonElement>(`button[data-tab="${name}"]`)
|
||||
if (!found) throw new Error(`no ${name} tab`)
|
||||
return found
|
||||
}
|
||||
|
||||
describe('untouched value (D-03)', () => {
|
||||
it.each(samples)('returns %s unchanged after a Source tab round trip', (_name, served) => {
|
||||
const { el, changes } = mount(served)
|
||||
expect(el.value).toBe(served)
|
||||
|
||||
el.showSource(true)
|
||||
expect(el.querySelector('textarea')!.value).toBe(served)
|
||||
expect(el.value).toBe(served)
|
||||
el.showSource(false)
|
||||
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it.each(samples)('returns %s unchanged after the tab buttons are clicked', (_name, served) => {
|
||||
const { el, changes } = mount(served)
|
||||
click(tab(el, 'source'))
|
||||
expect(tab(el, 'source').getAttribute('aria-selected')).toBe('true')
|
||||
click(tab(el, 'wysiwyg'))
|
||||
click(tab(el, 'source'))
|
||||
click(tab(el, 'wysiwyg'))
|
||||
expect(tab(el, 'wysiwyg').getAttribute('aria-selected')).toBe('true')
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it.each(samples)('returns %s unchanged when the value is set after connect and echoed', (_name, served) => {
|
||||
const { el, changes } = mount()
|
||||
el.value = served
|
||||
el.showSource(true)
|
||||
el.showSource(false)
|
||||
// The SPA writes the form value back only when it differs; an echo of
|
||||
// the same string must change nothing.
|
||||
el.value = el.value
|
||||
expect(el.value).toBe(served)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the imported post unchanged after the preview is opened and closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, changes } = mount(fixture)
|
||||
click(button(el, 'preview'))
|
||||
vi.advanceTimersByTime(1000)
|
||||
click(button(el, 'preview'))
|
||||
el.showSource(true)
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the imported post unchanged after a detach and a new attach', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.remove()
|
||||
expect(el.value).toBe(fixture)
|
||||
document.body.append(el)
|
||||
expect(el.value).toBe(fixture)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an edit in the Source tab once and keeps the edited text exactly', () => {
|
||||
const { el, changes } = mount(fixture)
|
||||
el.showSource(true)
|
||||
const textarea = el.querySelector('textarea')!
|
||||
const edited = fixture.replace('</h2>', ' (edytowane)</h2>')
|
||||
expect(edited).not.toBe(fixture)
|
||||
textarea.value = edited
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
el.showSource(false)
|
||||
expect(el.value).toBe(edited)
|
||||
expect(changes).toEqual([edited])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user