| 1 | package example_test |
| 2 | |
| 3 | import ( |
| 4 | "io/fs" |
| 5 | "path" |
| 6 | "regexp" |
| 7 | "slices" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "testing/fstest" |
| 13 | |
| 14 | "golang.org/x/net/html" |
| 15 | |
| 16 | "amadan.net/rastrillo/fiesta" |
| 17 | "amadan.net/rastrillo/fiesta/example" |
| 18 | ) |
| 19 | |
| 20 | // ── The package on a synthetic tree ────────────────────────────────── |
| 21 | |
| 22 | // synthBody is a body with a nested container and a <template>, the |
| 23 | // two shapes a tokenizer that reads structure rather than bytes gets |
| 24 | // wrong: a <section rst-box> is not a <body>, and a <template>'s |
| 25 | // content is inert in the DOM but is bytes on disk all the same. |
| 26 | const synthBody = `<section rst-box><form rst-form method="post" action="#"> |
| 27 | <template id="row"><div rst-field><label rst-field-label for="t">Title</label><input rst-input id="t" name="t"></div></template> |
| 28 | <div rst-field><label rst-field-label for="f_bare">Title</label><input rst-input id="f_bare" name="f_bare"></div> |
| 29 | </form></section>` |
| 30 | |
| 31 | func synthDoc(body string) string { |
| 32 | return "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n<head>\n<meta charset=\"utf-8\">\n" + |
| 33 | "<meta name=\"viewport\" content=\"width=device-width\">\n<title>field</title>\n" + |
| 34 | "<link rel=\"stylesheet\" href=\"../../css/tokens.css\">\n</head>\n<body>\n" + body + "\n</body>\n</html>\n" |
| 35 | } |
| 36 | |
| 37 | func synthFS() fstest.MapFS { |
| 38 | return fstest.MapFS{ |
| 39 | "examples/examples.json": {Data: []byte(`{"examples":[{"name":"field","title":"field","family":"form","kind":"component","document":false,"wrapper":"form","scripts":[],"default":"bare","states":[{"id":"bare","label":"Bare","sampleURLs":[]}]}],"idioms":{}}`)}, |
| 40 | "examples/field/bare.html": {Data: []byte(synthDoc(synthBody))}, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestMarkupReturnsTheBodyAsWritten(t *testing.T) { |
| 45 | got, err := example.Markup(synthFS(), "field", "bare") |
| 46 | if err != nil { |
| 47 | t.Fatalf("Markup: %v", err) |
| 48 | } |
| 49 | if got != synthBody { |
| 50 | t.Fatalf("Markup returned\n%s\nwant\n%s", got, synthBody) |
| 51 | } |
| 52 | // Exactly one newline is trimmed at each end: a body that ends in a |
| 53 | // blank line keeps it. |
| 54 | fsys := synthFS() |
| 55 | fsys["examples/field/blank.html"] = &fstest.MapFile{Data: []byte(synthDoc(synthBody + "\n"))} |
| 56 | got, err = example.Markup(fsys, "field", "blank") |
| 57 | if err != nil { |
| 58 | t.Fatalf("Markup: %v", err) |
| 59 | } |
| 60 | if got != synthBody+"\n" { |
| 61 | t.Fatalf("Markup trimmed more than one trailing newline:\n%q", got[len(got)-20:]) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestMarkupRefusesAMissingState(t *testing.T) { |
| 66 | if _, err := example.Markup(synthFS(), "field", "with-a-hint"); err == nil { |
| 67 | t.Fatal("Markup of a state with no file returned no error") |
| 68 | } |
| 69 | if _, err := example.Markup(synthFS(), "nothing", "bare"); err == nil { |
| 70 | t.Fatal("Markup of an example with no directory returned no error") |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestSlotReplacesOnlyTheSlot(t *testing.T) { |
| 75 | doc := "<main>\n<!-- fiesta:slot -->\n<h1>Posts</h1>\n<!-- /fiesta:slot -->\n</main>" |
| 76 | got, err := example.Slot(doc, "<p>Hello</p>") |
| 77 | if err != nil { |
| 78 | t.Fatalf("Slot: %v", err) |
| 79 | } |
| 80 | want := "<main>\n<!-- fiesta:slot --><p>Hello</p><!-- /fiesta:slot -->\n</main>" |
| 81 | if got != want { |
| 82 | t.Fatalf("Slot returned\n%s\nwant\n%s", got, want) |
| 83 | } |
| 84 | if _, err := example.Slot("<main><h1>Posts</h1></main>", "<p>Hello</p>"); err == nil { |
| 85 | t.Fatal("Slot on a document with no slot returned no error") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestSlotRefusesTwoSlots(t *testing.T) { |
| 90 | doc := "<!-- fiesta:slot -->a<!-- /fiesta:slot --><!-- fiesta:slot -->b<!-- /fiesta:slot -->" |
| 91 | if _, err := example.Slot(doc, "c"); err == nil { |
| 92 | t.Fatal("Slot on a document with two slots returned no error") |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestSlotRefusesReversedMarkers(t *testing.T) { |
| 97 | doc := "<main><!-- /fiesta:slot -->a<!-- fiesta:slot --></main>" |
| 98 | if _, err := example.Slot(doc, "c"); err == nil { |
| 99 | t.Fatal("Slot on a document whose slot closes before it opens returned no error") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // ── The real examples ──────────────────────────────────────────────── |
| 104 | |
| 105 | // tree is the embedded design system, which carries examples/ from |
| 106 | // this task on. |
| 107 | func tree(t *testing.T) fs.FS { |
| 108 | t.Helper() |
| 109 | fsys := fiesta.FS() |
| 110 | if _, err := fs.Stat(fsys, "examples/examples.json"); err != nil { |
| 111 | t.Fatalf("the embedded tree has no examples/examples.json: %v", err) |
| 112 | } |
| 113 | return fsys |
| 114 | } |
| 115 | |
| 116 | func manifest(t *testing.T) (fs.FS, *example.Manifest) { |
| 117 | t.Helper() |
| 118 | fsys := tree(t) |
| 119 | m, err := example.Load(fsys) |
| 120 | if err != nil { |
| 121 | t.Fatalf("Load: %v", err) |
| 122 | } |
| 123 | if len(m.Examples) == 0 { |
| 124 | t.Fatal("the manifest lists no examples") |
| 125 | } |
| 126 | return fsys, m |
| 127 | } |
| 128 | |
| 129 | func TestDefaultIsAState(t *testing.T) { |
| 130 | _, m := manifest(t) |
| 131 | for _, e := range m.Examples { |
| 132 | id, ok := m.Default(e.Name) |
| 133 | if !ok { |
| 134 | t.Errorf("%s: Default reports no default", e.Name) |
| 135 | continue |
| 136 | } |
| 137 | if id != e.Default { |
| 138 | t.Errorf("%s: Default returned %q, the entry says %q", e.Name, id, e.Default) |
| 139 | } |
| 140 | if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == id }) { |
| 141 | t.Errorf("%s: the default state %q is not one of its states", e.Name, id) |
| 142 | } |
| 143 | } |
| 144 | if _, ok := m.Default("no-such-example"); ok { |
| 145 | t.Error("Default of an unknown example reports ok") |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | var ( |
| 150 | kinds = []string{"component", "idiom", "layout", "format", "screen"} |
| 151 | wrappers = []string{"", "list", "form", "box", "stats"} |
| 152 | ) |
| 153 | |
| 154 | func TestTheManifestMatchesTheFiles(t *testing.T) { |
| 155 | fsys, m := manifest(t) |
| 156 | scripts := fiesta.ScriptNames() |
| 157 | seen := map[string]bool{} |
| 158 | for _, e := range m.Examples { |
| 159 | if seen[e.Name] { |
| 160 | t.Errorf("%s is listed twice", e.Name) |
| 161 | } |
| 162 | seen[e.Name] = true |
| 163 | if !slices.Contains(kinds, e.Kind) { |
| 164 | t.Errorf("%s: kind %q is not one of %v", e.Name, e.Kind, kinds) |
| 165 | } |
| 166 | if !slices.Contains(wrappers, e.Wrapper) { |
| 167 | t.Errorf("%s: wrapper %q is not one of %v", e.Name, e.Wrapper, wrappers) |
| 168 | } |
| 169 | for _, s := range e.Scripts { |
| 170 | if !slices.Contains(scripts, s) { |
| 171 | t.Errorf("%s: script %q is not one fiesta ships (%v)", e.Name, s, scripts) |
| 172 | } |
| 173 | } |
| 174 | entries, err := fs.ReadDir(fsys, path.Join("examples", e.Name)) |
| 175 | if err != nil { |
| 176 | t.Errorf("%s: no directory: %v", e.Name, err) |
| 177 | continue |
| 178 | } |
| 179 | files := map[string]bool{} |
| 180 | for _, f := range entries { |
| 181 | files[f.Name()] = true |
| 182 | } |
| 183 | ids := map[string]bool{} |
| 184 | if len(e.States) == 0 { |
| 185 | t.Errorf("%s: no states", e.Name) |
| 186 | } |
| 187 | for _, s := range e.States { |
| 188 | if ids[s.ID] { |
| 189 | t.Errorf("%s: state id %q is used twice", e.Name, s.ID) |
| 190 | } |
| 191 | ids[s.ID] = true |
| 192 | if !files[s.ID+".html"] { |
| 193 | t.Errorf("%s: state %q has no file", e.Name, s.ID) |
| 194 | } |
| 195 | delete(files, s.ID+".html") |
| 196 | } |
| 197 | for f := range files { |
| 198 | t.Errorf("%s: %s is not a state in the manifest", e.Name, f) |
| 199 | } |
| 200 | if _, ok := m.Entry(e.Name); !ok { |
| 201 | t.Errorf("%s: Entry reports no entry", e.Name) |
| 202 | } |
| 203 | } |
| 204 | entries, err := fs.ReadDir(fsys, "examples") |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | for _, d := range entries { |
| 209 | if d.IsDir() && d.Name() != "assets" && !seen[d.Name()] { |
| 210 | t.Errorf("examples/%s is a directory no entry names", d.Name()) |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // attr returns one attribute of a start tag, and whether it is there. |
| 216 | func attr(tok html.Token, name string) (string, bool) { |
| 217 | for _, a := range tok.Attr { |
| 218 | if a.Key == name { |
| 219 | return a.Val, true |
| 220 | } |
| 221 | } |
| 222 | return "", false |
| 223 | } |
| 224 | |
| 225 | // walk tokenizes one document and hands each start or self-closing |
| 226 | // tag to fn with whether it sits inside <head>. |
| 227 | func walk(t *testing.T, doc string, fn func(tok html.Token, inHead bool)) { |
| 228 | t.Helper() |
| 229 | z := html.NewTokenizer(strings.NewReader(doc)) |
| 230 | inHead := false |
| 231 | for { |
| 232 | switch z.Next() { |
| 233 | case html.ErrorToken: |
| 234 | return |
| 235 | case html.StartTagToken, html.SelfClosingTagToken: |
| 236 | tok := z.Token() |
| 237 | if tok.Data == "head" { |
| 238 | inHead = true |
| 239 | } |
| 240 | fn(tok, inHead) |
| 241 | case html.EndTagToken: |
| 242 | if z.Token().Data == "head" { |
| 243 | inHead = false |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func everyDocument(t *testing.T, fn func(e example.Entry, s example.State, doc string)) { |
| 250 | t.Helper() |
| 251 | fsys, m := manifest(t) |
| 252 | for _, e := range m.Examples { |
| 253 | for _, s := range e.States { |
| 254 | doc, err := example.Document(fsys, e.Name, s.ID) |
| 255 | if err != nil { |
| 256 | t.Errorf("%s/%s: %v", e.Name, s.ID, err) |
| 257 | continue |
| 258 | } |
| 259 | fn(e, s, doc) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestExamplesLinkOnlyFiestasOwnFiles(t *testing.T) { |
| 265 | fsys := tree(t) |
| 266 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 267 | where := e.Name + "/" + s.ID |
| 268 | walk(t, doc, func(tok html.Token, inHead bool) { |
| 269 | for _, name := range []string{"href", "src"} { |
| 270 | v, ok := attr(tok, name) |
| 271 | if !ok || !inHead { |
| 272 | continue |
| 273 | } |
| 274 | if !strings.HasPrefix(v, "../../css/") && !strings.HasPrefix(v, "../../js/") { |
| 275 | t.Errorf("%s: the head's %s=%q is not under ../../css or ../../js", where, name, v) |
| 276 | continue |
| 277 | } |
| 278 | if _, err := fs.Stat(fsys, strings.TrimPrefix(v, "../../")); err != nil { |
| 279 | t.Errorf("%s: the head links %s, which the tree does not have", where, v) |
| 280 | } |
| 281 | } |
| 282 | if inHead { |
| 283 | return |
| 284 | } |
| 285 | for _, name := range []string{"src", "srcset", "poster"} { |
| 286 | v, ok := attr(tok, name) |
| 287 | if !ok { |
| 288 | continue |
| 289 | } |
| 290 | for _, cand := range strings.Split(v, ",") { |
| 291 | fields := strings.Fields(cand) |
| 292 | if len(fields) == 0 { |
| 293 | t.Errorf("%s: the body's %s=%q has an empty candidate", where, name, v) |
| 294 | continue |
| 295 | } |
| 296 | file := fields[0] |
| 297 | if !strings.HasPrefix(file, "../assets/") { |
| 298 | t.Errorf("%s: the body's %s=%q is not under ../assets/", where, name, v) |
| 299 | continue |
| 300 | } |
| 301 | if _, err := fs.Stat(fsys, path.Join("examples", strings.TrimPrefix(file, "../"))); err != nil { |
| 302 | t.Errorf("%s: the body names %s, which examples/assets does not have", where, file) |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | }) |
| 307 | }) |
| 308 | } |
| 309 | |
| 310 | func TestEveryIdiomIsMapped(t *testing.T) { |
| 311 | _, m := manifest(t) |
| 312 | if len(m.Idioms) == 0 { |
| 313 | t.Fatal("the manifest maps no idioms") |
| 314 | } |
| 315 | for key, ref := range m.Idioms { |
| 316 | name, state, ok := m.Idiom(key) |
| 317 | if !ok || name != ref.Example || state != ref.State { |
| 318 | t.Errorf("Idiom(%q) = %q, %q, %v; the manifest says %q, %q", key, name, state, ok, ref.Example, ref.State) |
| 319 | } |
| 320 | e, ok := m.Entry(ref.Example) |
| 321 | if !ok { |
| 322 | t.Errorf("idiom %q names example %q, which does not exist", key, ref.Example) |
| 323 | continue |
| 324 | } |
| 325 | if !slices.ContainsFunc(e.States, func(s example.State) bool { return s.ID == ref.State }) { |
| 326 | t.Errorf("idiom %q names state %q of %q, which does not exist", key, ref.State, ref.Example) |
| 327 | } |
| 328 | } |
| 329 | if name, state, ok := m.Idiom("dropdown"); !ok || name != "dropdown" || state != "menu-idiom" { |
| 330 | t.Errorf("Idiom(dropdown) = %q, %q, %v; want dropdown, menu-idiom, true", name, state, ok) |
| 331 | } |
| 332 | if _, _, ok := m.Idiom("no-such-idiom"); ok { |
| 333 | t.Error("Idiom of an unknown key reports ok") |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | // applicationURL says whether a link or action is one of the routes a |
| 338 | // sample stands for: not a fragment, not one of Fiesta's own files and |
| 339 | // not one of the example pictures. |
| 340 | func applicationURL(v string) bool { |
| 341 | return v != "" && !strings.HasPrefix(v, "#") && !strings.HasPrefix(v, "../../") && !strings.HasPrefix(v, "../assets/") |
| 342 | } |
| 343 | |
| 344 | func TestSampleURLsAreDeclared(t *testing.T) { |
| 345 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 346 | where := e.Name + "/" + s.ID |
| 347 | found := map[string]bool{} |
| 348 | walk(t, doc, func(tok html.Token, inHead bool) { |
| 349 | if inHead { |
| 350 | return |
| 351 | } |
| 352 | if v, ok := attr(tok, "href"); ok && applicationURL(v) { |
| 353 | found[v] = true |
| 354 | } |
| 355 | if v, ok := attr(tok, "action"); ok && tok.Data == "form" && applicationURL(v) { |
| 356 | found[v] = true |
| 357 | } |
| 358 | }) |
| 359 | var want []string |
| 360 | for v := range found { |
| 361 | want = append(want, v) |
| 362 | } |
| 363 | sort.Strings(want) |
| 364 | got := s.SampleURLs |
| 365 | if got == nil { |
| 366 | got = []string{} |
| 367 | } |
| 368 | if !sort.StringsAreSorted(got) { |
| 369 | t.Errorf("%s: sampleURLs are not sorted: %v", where, got) |
| 370 | } |
| 371 | if !slices.Equal(got, want) { |
| 372 | t.Errorf("%s: sampleURLs are %v; the markup links %v", where, got, want) |
| 373 | } |
| 374 | }) |
| 375 | } |
| 376 | |
| 377 | func TestEveryStateIsValidOnItsOwn(t *testing.T) { |
| 378 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 379 | where := e.Name + "/" + s.ID |
| 380 | ids := map[string]bool{} |
| 381 | type ref struct{ attr, id string } |
| 382 | var refs []ref |
| 383 | walk(t, doc, func(tok html.Token, _ bool) { |
| 384 | if id, ok := attr(tok, "id"); ok { |
| 385 | if ids[id] { |
| 386 | t.Errorf("%s: id %q is used twice", where, id) |
| 387 | } |
| 388 | ids[id] = true |
| 389 | } |
| 390 | if v, ok := attr(tok, "for"); ok { |
| 391 | refs = append(refs, ref{"for", v}) |
| 392 | } |
| 393 | for _, name := range []string{"aria-labelledby", "aria-describedby", "aria-controls"} { |
| 394 | if v, ok := attr(tok, name); ok { |
| 395 | for _, id := range strings.Fields(v) { |
| 396 | refs = append(refs, ref{name, id}) |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | }) |
| 401 | for _, r := range refs { |
| 402 | if !ids[r.id] { |
| 403 | t.Errorf("%s: %s=%q names no id in the document", where, r.attr, r.id) |
| 404 | } |
| 405 | } |
| 406 | }) |
| 407 | } |
| 408 | |
| 409 | // headProblems lists what is wrong with one document's standard head: |
| 410 | // the doctype, <html lang="en" dir="ltr"> (with the scheme store |
| 411 | // attribute only where store says so), the charset, the viewport, a |
| 412 | // non-empty <title>, and the two stylesheets. |
| 413 | func headProblems(t *testing.T, doc string, store bool) []string { |
| 414 | t.Helper() |
| 415 | var problems []string |
| 416 | if !strings.HasPrefix(doc, "<!doctype html>\n") { |
| 417 | problems = append(problems, "no <!doctype html> first") |
| 418 | } |
| 419 | var sawHTML, charset, viewport, title, tokens, day bool |
| 420 | walk(t, doc, func(tok html.Token, inHead bool) { |
| 421 | switch { |
| 422 | case tok.Data == "html": |
| 423 | sawHTML = true |
| 424 | if v, _ := attr(tok, "lang"); v != "en" { |
| 425 | problems = append(problems, "<html> lang is "+strconv.Quote(v)+", want en") |
| 426 | } |
| 427 | if v, _ := attr(tok, "dir"); v != "ltr" { |
| 428 | problems = append(problems, "<html> dir is "+strconv.Quote(v)+", want ltr") |
| 429 | } |
| 430 | if v, ok := attr(tok, "data-rst-scheme-store"); ok != store || (ok && v != "local") { |
| 431 | problems = append(problems, "<html> data-rst-scheme-store is "+strconv.Quote(v)+", present "+strconv.FormatBool(ok)+", want present "+strconv.FormatBool(store)) |
| 432 | } |
| 433 | case !inHead: |
| 434 | case tok.Data == "meta": |
| 435 | if v, ok := attr(tok, "charset"); ok && strings.EqualFold(v, "utf-8") { |
| 436 | charset = true |
| 437 | } |
| 438 | if v, _ := attr(tok, "name"); v == "viewport" { |
| 439 | viewport = true |
| 440 | } |
| 441 | case tok.Data == "link": |
| 442 | v, _ := attr(tok, "href") |
| 443 | tokens = tokens || v == "../../css/tokens.css" |
| 444 | day = day || v == "../../css/themes/day.css" |
| 445 | } |
| 446 | }) |
| 447 | if m := titlePattern.FindStringSubmatch(doc); m != nil && strings.TrimSpace(m[1]) != "" { |
| 448 | title = true |
| 449 | } |
| 450 | for _, c := range []struct { |
| 451 | ok bool |
| 452 | what string |
| 453 | }{{sawHTML, "no <html> tag"}, {charset, "no <meta charset=\"utf-8\"> in the head"}, {viewport, "no viewport meta in the head"}, |
| 454 | {title, "no non-empty <title>"}, {tokens, "the head does not link ../../css/tokens.css"}, {day, "the head does not link ../../css/themes/day.css"}} { |
| 455 | if !c.ok { |
| 456 | problems = append(problems, c.what) |
| 457 | } |
| 458 | } |
| 459 | return problems |
| 460 | } |
| 461 | |
| 462 | var titlePattern = regexp.MustCompile(`(?s)<title>(.*?)</title>`) |
| 463 | |
| 464 | // TestEveryExampleHasTheStandardHead holds the example file format |
| 465 | // the exporter wrote and the README now calls hand-owned: a hand edit |
| 466 | // that drops the viewport or the theme fails here. The control leg |
| 467 | // plants each fault in a copy of a real document and expects the |
| 468 | // checker to name it, so the pass below is not vacuous. |
| 469 | func TestEveryExampleHasTheStandardHead(t *testing.T) { |
| 470 | fsys, m := manifest(t) |
| 471 | t.Run("control", func(t *testing.T) { |
| 472 | doc, err := example.Document(fsys, m.Examples[0].Name, m.Examples[0].Default) |
| 473 | if err != nil { |
| 474 | t.Fatal(err) |
| 475 | } |
| 476 | for _, c := range []struct { |
| 477 | name, from, to, want string |
| 478 | }{ |
| 479 | {"doctype", "<!doctype html>\n", "", "no <!doctype html> first"}, |
| 480 | {"lang", `lang="en"`, `lang="es"`, `<html> lang is "es", want en`}, |
| 481 | {"viewport", `<meta name="viewport" content="width=device-width">`, "", "no viewport meta in the head"}, |
| 482 | {"theme", `href="../../css/themes/day.css"`, `href="../../css/themes/night.css"`, "the head does not link ../../css/themes/day.css"}, |
| 483 | {"store", `dir="ltr"`, `dir="ltr" data-rst-scheme-store="local"`, `<html> data-rst-scheme-store is "local", present true, want present false`}, |
| 484 | } { |
| 485 | mutated := strings.Replace(doc, c.from, c.to, 1) |
| 486 | if mutated == doc { |
| 487 | t.Fatalf("%s: the mutation changed nothing; the control proves nothing", c.name) |
| 488 | } |
| 489 | if got := headProblems(t, mutated, false); !slices.Contains(got, c.want) { |
| 490 | t.Errorf("%s: the checker reported %v, want it to include %q", c.name, got, c.want) |
| 491 | } |
| 492 | } |
| 493 | if got := headProblems(t, doc, false); len(got) != 0 { |
| 494 | t.Errorf("the unmutated document reports %v", got) |
| 495 | } |
| 496 | }) |
| 497 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 498 | store := e.Name == "scheme-menu" && s.ID == "standalone" |
| 499 | for _, p := range headProblems(t, doc, store) { |
| 500 | t.Errorf("%s/%s: %s", e.Name, s.ID, p) |
| 501 | } |
| 502 | }) |
| 503 | } |
| 504 | |
| 505 | // layoutProblems lists what is wrong with a layout entry and its one |
| 506 | // document: it has exactly one state, default, which is its Default, |
| 507 | // and the document has exactly one slot. A non-layout document has no |
| 508 | // slot at all. |
| 509 | func layoutProblems(e example.Entry, doc string) []string { |
| 510 | var problems []string |
| 511 | if !e.Document { |
| 512 | if strings.Contains(doc, "<!-- fiesta:slot -->") || strings.Contains(doc, "<!-- /fiesta:slot -->") { |
| 513 | problems = append(problems, "a non-layout document carries a slot marker") |
| 514 | } |
| 515 | return problems |
| 516 | } |
| 517 | if len(e.States) != 1 || e.States[0].ID != "default" { |
| 518 | var ids []string |
| 519 | for _, s := range e.States { |
| 520 | ids = append(ids, s.ID) |
| 521 | } |
| 522 | problems = append(problems, "a layout must have the one state default, has "+strings.Join(ids, ", ")) |
| 523 | } |
| 524 | if e.Default != "default" { |
| 525 | problems = append(problems, "a layout's default must be default, is "+e.Default) |
| 526 | } |
| 527 | if _, err := example.Slot(doc, "<p>x</p>"); err != nil { |
| 528 | problems = append(problems, err.Error()) |
| 529 | } |
| 530 | return problems |
| 531 | } |
| 532 | |
| 533 | func TestEveryLayoutHasOneDefaultAndOneSlot(t *testing.T) { |
| 534 | fsys, m := manifest(t) |
| 535 | t.Run("control", func(t *testing.T) { |
| 536 | e, ok := m.Entry("layout-sidebar") |
| 537 | if !ok || !e.Document { |
| 538 | t.Fatal("no layout-sidebar layout to mutate") |
| 539 | } |
| 540 | doc, err := example.Document(fsys, e.Name, "default") |
| 541 | if err != nil { |
| 542 | t.Fatal(err) |
| 543 | } |
| 544 | two := e |
| 545 | two.States = append(append([]example.State(nil), e.States...), example.State{ID: "wide"}) |
| 546 | if got := layoutProblems(two, doc); len(got) == 0 { |
| 547 | t.Error("an entry with two states passed") |
| 548 | } |
| 549 | if got := layoutProblems(e, strings.Replace(doc, "<!-- fiesta:slot -->", "<!-- fiesta:slot --><!-- fiesta:slot -->", 1)); len(got) == 0 { |
| 550 | t.Error("a document with two slots passed") |
| 551 | } |
| 552 | if got := layoutProblems(e, strings.ReplaceAll(doc, "<!-- fiesta:slot -->", "")); len(got) == 0 { |
| 553 | t.Error("a document with no slot passed") |
| 554 | } |
| 555 | plain := e |
| 556 | plain.Document = false |
| 557 | if got := layoutProblems(plain, doc); len(got) == 0 { |
| 558 | t.Error("a non-layout document with a slot passed") |
| 559 | } |
| 560 | if got := layoutProblems(e, doc); len(got) != 0 { |
| 561 | t.Errorf("the unmutated layout reports %v", got) |
| 562 | } |
| 563 | }) |
| 564 | layouts := 0 |
| 565 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 566 | if e.Document { |
| 567 | layouts++ |
| 568 | } |
| 569 | for _, p := range layoutProblems(e, doc) { |
| 570 | t.Errorf("%s/%s: %s", e.Name, s.ID, p) |
| 571 | } |
| 572 | }) |
| 573 | if layouts != 7 { |
| 574 | t.Errorf("%d layout documents, want 7", layouts) |
| 575 | } |
| 576 | } |
| 577 | |
| 578 | func TestTheStandaloneSchemeStateOwnsItsStorage(t *testing.T) { |
| 579 | fsys, m := manifest(t) |
| 580 | def, ok := m.Default("scheme-menu") |
| 581 | if !ok { |
| 582 | t.Fatal("no scheme-menu example") |
| 583 | } |
| 584 | if def != "standalone" { |
| 585 | t.Errorf("scheme-menu's default is %q, want standalone", def) |
| 586 | } |
| 587 | htmlAttr := func(doc string) (string, bool) { |
| 588 | var v string |
| 589 | var found bool |
| 590 | walk(t, doc, func(tok html.Token, _ bool) { |
| 591 | if tok.Data == "html" { |
| 592 | v, found = attr(tok, "data-rst-scheme-store") |
| 593 | } |
| 594 | }) |
| 595 | return v, found |
| 596 | } |
| 597 | doc, err := example.Document(fsys, "scheme-menu", "standalone") |
| 598 | if err != nil { |
| 599 | t.Fatal(err) |
| 600 | } |
| 601 | if v, ok := htmlAttr(doc); !ok || v != "local" { |
| 602 | t.Errorf("scheme-menu/standalone: <html> carries data-rst-scheme-store=%q, %v; want \"local\"", v, ok) |
| 603 | } |
| 604 | if strings.Contains(doc, " action=") { |
| 605 | t.Error("scheme-menu/standalone: a form still names an action") |
| 606 | } |
| 607 | everyDocument(t, func(e example.Entry, s example.State, doc string) { |
| 608 | if e.Name == "scheme-menu" && s.ID == "standalone" { |
| 609 | return |
| 610 | } |
| 611 | if _, ok := htmlAttr(doc); ok { |
| 612 | t.Errorf("%s/%s: <html> carries data-rst-scheme-store; only scheme-menu/standalone does", e.Name, s.ID) |
| 613 | } |
| 614 | }) |
| 615 | } |
| 616 | |
| 617 | func TestAllWalksTheManifestInOrder(t *testing.T) { |
| 618 | fsys, m := manifest(t) |
| 619 | pieces, err := example.All(fsys) |
| 620 | if err != nil { |
| 621 | t.Fatal(err) |
| 622 | } |
| 623 | var want []string |
| 624 | for _, e := range m.Examples { |
| 625 | if e.Document { |
| 626 | continue |
| 627 | } |
| 628 | for _, s := range e.States { |
| 629 | want = append(want, e.Name+"/"+s.ID) |
| 630 | } |
| 631 | } |
| 632 | var got []string |
| 633 | for _, p := range pieces { |
| 634 | got = append(got, p.Name+"/"+p.State) |
| 635 | markup, err := example.Markup(fsys, p.Name, p.State) |
| 636 | if err != nil { |
| 637 | t.Errorf("%s/%s: %v", p.Name, p.State, err) |
| 638 | } else if markup != p.Markup { |
| 639 | t.Errorf("%s/%s: All's markup differs from Markup's", p.Name, p.State) |
| 640 | } |
| 641 | if strings.Contains(p.Markup, "<body") || strings.Contains(p.Markup, "</body>") { |
| 642 | t.Errorf("%s/%s: the markup holds a body tag", p.Name, p.State) |
| 643 | } |
| 644 | } |
| 645 | if !slices.Equal(got, want) { |
| 646 | t.Errorf("All returned %d pieces in this order:\n%v\nwant %d:\n%v", len(got), got, len(want), want) |
| 647 | } |
| 648 | } |
| 649 | |
| 650 | // ── Reading one component out of its row ───────────────────────────── |
| 651 | |
| 652 | // rowed is a dropdown in a section head, the shape the reworked states |
| 653 | // take: a heading, then the menu, whose panel holds a nested <details> |
| 654 | // that a reader counting the wrong tag would end the menu on. |
| 655 | const ( |
| 656 | rowedMenu = `<details rst-dropdown name="a"><summary>S</summary><div rst-dropdown-menu><details rst-menu-group><summary>G</summary></details></div></details>` |
| 657 | rowed = `<div rst-box-head><h2>Posts</h2>` + rowedMenu + `</div>` |
| 658 | ) |
| 659 | |
| 660 | func TestElementReturnsTheCarrierAsWritten(t *testing.T) { |
| 661 | got, err := example.Element(rowed, "rst-dropdown") |
| 662 | if err != nil { |
| 663 | t.Fatalf("Element: %v", err) |
| 664 | } |
| 665 | if got != rowedMenu { |
| 666 | t.Fatalf("Element returned\n%s\nwant\n%s", got, rowedMenu) |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | func TestContentsReturnsWhatLiesInside(t *testing.T) { |
| 671 | got, err := example.Contents(rowed, "rst-box-head") |
| 672 | if err != nil { |
| 673 | t.Fatalf("Contents: %v", err) |
| 674 | } |
| 675 | if want := `<h2>Posts</h2>` + rowedMenu; got != want { |
| 676 | t.Fatalf("Contents returned\n%s\nwant\n%s", got, want) |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | func TestElementReadsAVoidElement(t *testing.T) { |
| 681 | got, err := example.Element(`<p><input rst-input name="q"></p>`, "rst-input") |
| 682 | if err != nil { |
| 683 | t.Fatalf("Element: %v", err) |
| 684 | } |
| 685 | if want := `<input rst-input name="q">`; got != want { |
| 686 | t.Fatalf("Element returned %q, want %q", got, want) |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | // refusal holds one call to an error that names the attribute, since |
| 691 | // an error that does not say which component was missing sends its |
| 692 | // reader through every row on the page. |
| 693 | func refusal(t *testing.T, markup, why string) { |
| 694 | t.Helper() |
| 695 | got, err := example.Element(markup, "rst-dropdown") |
| 696 | if err == nil { |
| 697 | t.Fatalf("Element of %s returned %q and no error", why, got) |
| 698 | } |
| 699 | if !strings.Contains(err.Error(), "rst-dropdown") { |
| 700 | t.Errorf("Element of %s: the error %q does not name rst-dropdown", why, err) |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | func TestElementRefusesAMissingCarrier(t *testing.T) { |
| 705 | refusal(t, `<div rst-box-head><h2>Posts</h2></div>`, "markup with no dropdown") |
| 706 | } |
| 707 | |
| 708 | func TestElementRefusesTwoCarriers(t *testing.T) { |
| 709 | refusal(t, `<div rst-box-head><h2>Posts</h2>`+rowedMenu+rowedMenu+`</div>`, "two sibling dropdowns") |
| 710 | } |
| 711 | |
| 712 | func TestElementRefusesAnUnclosedCarrier(t *testing.T) { |
| 713 | refusal(t, `<div rst-box-head><h2>Posts</h2><details rst-dropdown name="a"><summary>S</summary>`, "a dropdown cut short") |
| 714 | } |
| 715 | |
| 716 | func TestANestedCarrierCountsOnce(t *testing.T) { |
| 717 | outer := `<details rst-dropdown name="o"><summary>O</summary><div rst-dropdown-menu>` + rowedMenu + `</div></details>` |
| 718 | got, err := example.Element(`<div rst-box-head>`+outer+`</div>`, "rst-dropdown") |
| 719 | if err != nil { |
| 720 | t.Fatalf("Element: %v", err) |
| 721 | } |
| 722 | if got != outer { |
| 723 | t.Fatalf("Element returned\n%s\nwant the outer dropdown\n%s", got, outer) |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | // TestEachRowedStateHoldsOneComponent: the five component states that |
| 728 | // show their menu in a row still give a reader the bare component, the |
| 729 | // way part 2's golden tests and the gallery take it out: Element for |
| 730 | // four, and for the menu idiom Contents of its toolbar, since the idiom |
| 731 | // is the menu and its applied-filter chip together. The standalone |
| 732 | // scheme state's component is the partial with its forms' action |
| 733 | // removed, as the seeder made it, so it must carry no action. |
| 734 | func TestEachRowedStateHoldsOneComponent(t *testing.T) { |
| 735 | cases := []struct { |
| 736 | name, state, attribute string |
| 737 | read func(markup, attribute string) (string, error) |
| 738 | }{ |
| 739 | {"dropdown", "a-filter-menu-with-one-choice-applied", "rst-dropdown", example.Element}, |
| 740 | {"dropdown", "menu-idiom", "rst-lbar", example.Contents}, |
| 741 | {"locale-menu", "twelve-languages-current-highlighted", "rst-locale", example.Element}, |
| 742 | {"scheme-menu", "three-choices-dark-current", "rst-scheme", example.Element}, |
| 743 | {"scheme-menu", "standalone", "rst-scheme", example.Element}, |
| 744 | } |
| 745 | for _, c := range cases { |
| 746 | m, err := example.Markup(fiesta.FS(), c.name, c.state) |
| 747 | if err != nil { |
| 748 | t.Fatal(err) |
| 749 | } |
| 750 | component, err := c.read(m, c.attribute) |
| 751 | if err != nil { |
| 752 | t.Errorf("%s/%s: %v", c.name, c.state, err) |
| 753 | continue |
| 754 | } |
| 755 | switch c.name + "/" + c.state { |
| 756 | case "dropdown/menu-idiom": |
| 757 | for _, part := range []string{"rst-dropdown", "rst-ftok"} { |
| 758 | if _, err := example.Element(component, part); err != nil { |
| 759 | t.Errorf("dropdown/menu-idiom: the toolbar should hold one %s: %v", part, err) |
| 760 | } |
| 761 | } |
| 762 | case "scheme-menu/standalone": |
| 763 | if strings.Contains(component, " action=") { |
| 764 | t.Error("scheme-menu/standalone: the component's forms name an action") |
| 765 | } |
| 766 | } |
| 767 | } |
| 768 | } |
| 769 | |