| 1 | package codeview |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "unicode" |
| 7 | ) |
| 8 | |
| 9 | // squash is markup with every whitespace character taken out. Format |
| 10 | // may add, move and drop whitespace and must touch nothing else, so the |
| 11 | // two sides of a formatting agree once squashed. Whether the whitespace |
| 12 | // it moved could show is the browser's question, not this one's, and |
| 13 | // no drive here asks it yet (see the note above flexAttrs). |
| 14 | func squash(s string) string { |
| 15 | return strings.Map(func(r rune) rune { |
| 16 | if unicode.IsSpace(r) { |
| 17 | return -1 |
| 18 | } |
| 19 | return r |
| 20 | }, s) |
| 21 | } |
| 22 | |
| 23 | var formatCases = []string{ |
| 24 | `<section rst-box><form rst-form method="post" action="#"><div rst-field><label rst-field-label for="t">Title</label><input rst-input id="t" name="t"></div></form></section>`, |
| 25 | `<div rst-list><div rst-lrow><a class="rst-nm" href="/p/1">Release notes<small>2 August</small></a> <span rst-pill>Draft</span></div></div>`, |
| 26 | `<span rst-meter><meter rst-meter-bar value="100" min="0" max="100" aria-hidden="true"></meter><span rst-meter-num>700/500</span></span>`, |
| 27 | "<ul>\n <li>One</li>\n\n<li>Two</li></ul><p>After</p>", |
| 28 | `<details rst-dropdown name="rst-menus"><summary>Filter<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></span></summary><div rst-dropdown-menu><a href="/a">A</a></div></details>`, |
| 29 | `<div rst-form-foot><button rst-btn="primary lg" type="submit" aria-busy="true" disabled><span rst-spin aria-hidden="true"></span>Publish</button><a rst-btn="lg" href="/posts">Back</a></div>`, |
| 30 | `<form rst-form-actions method="post"><input type="hidden" name="csrf" value="t"><input type="hidden" name="id" value="1"><a rst-btn href="/p">Cancel</a><button rst-btn="danger">Delete</button></form>`, |
| 31 | } |
| 32 | |
| 33 | func TestFormatChangesOnlyWhitespaceAndIsIdempotent(t *testing.T) { |
| 34 | for _, in := range formatCases { |
| 35 | out := Format(in) |
| 36 | if squash(out) != squash(in) { |
| 37 | t.Errorf("Format changed more than whitespace:\n in: %s\nout: %s", in, out) |
| 38 | } |
| 39 | if Format(out) != out { |
| 40 | t.Errorf("Format is not idempotent on:\n%s\nsecond pass:\n%s", out, Format(out)) |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // The space between two inline elements, and between text and an |
| 46 | // inline element, is rendered wherever the container is not one the |
| 47 | // formatter knows lays its children out as flex or grid, so Format |
| 48 | // keeps it exactly. An unknown container stays inline. |
| 49 | func TestFormatKeepsTheSpaceBetweenInlineElements(t *testing.T) { |
| 50 | for in, want := range map[string]string{ |
| 51 | `<div><a href="/a">A</a> <span>B</span></div>`: "<div>\n <a href=\"/a\">A</a> <span>B</span>\n</div>", |
| 52 | `<p>Hello <b>world</b> <i>again</i></p>`: "<p>Hello <b>world</b> <i>again</i></p>", |
| 53 | `<span rst-a>x</span> <span rst-b>y</span>`: `<span rst-a>x</span> <span rst-b>y</span>`, |
| 54 | "<li><a href=\"/x\">X</a>\n<a href=\"/y\">Y</a></li>": "<li>\n <a href=\"/x\">X</a>\n<a href=\"/y\">Y</a>\n</li>", |
| 55 | `<p>Total <output name="t">$1</output> due</p>`: `<p>Total <output name="t">$1</output> due</p>`, |
| 56 | `<label rst-field-label for="a">Name <span rst-field-required aria-hidden="true">*</span></label>`: `<label rst-field-label for="a">Name <span rst-field-required aria-hidden="true">*</span></label>`, |
| 57 | `<div rst-unknown><span>a</span><span>b</span></div>`: "<div rst-unknown>\n <span>a</span><span>b</span>\n</div>", |
| 58 | } { |
| 59 | if got := Format(in); got != want { |
| 60 | t.Errorf("Format(%q)\n got %q\nwant %q", in, got, want) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestFormatBreaksAtBlockTagsAndIndents(t *testing.T) { |
| 66 | got := Format(`<section rst-box><form><div rst-field><label>Title</label><input name="t"></div></form></section>`) |
| 67 | want := "<section rst-box>\n <form>\n <div rst-field>\n <label>Title</label><input name=\"t\">\n </div>\n </form>\n</section>" |
| 68 | if got != want { |
| 69 | t.Errorf("got\n%s\nwant\n%s", got, want) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // Inside a flex or grid container whitespace between children renders |
| 74 | // as nothing, so every child gets a line: the busy button's spinner and |
| 75 | // its label, and the two buttons beside each other. A button holding |
| 76 | // only its label keeps it inline. |
| 77 | func TestFormatPutsEachChildOfAFlexContainerOnALine(t *testing.T) { |
| 78 | got := Format(formatCases[5]) |
| 79 | want := "<div rst-form-foot>\n" + |
| 80 | " <button rst-btn=\"primary lg\" type=\"submit\" aria-busy=\"true\" disabled>\n" + |
| 81 | " <span rst-spin aria-hidden=\"true\"></span>\n" + |
| 82 | " Publish\n" + |
| 83 | " </button>\n" + |
| 84 | " <a rst-btn=\"lg\" href=\"/posts\">Back</a>\n" + |
| 85 | "</div>" |
| 86 | if got != want { |
| 87 | t.Errorf("got\n%s\nwant\n%s", got, want) |
| 88 | } |
| 89 | got = Format(formatCases[2]) |
| 90 | want = "<span rst-meter>\n <meter rst-meter-bar value=\"100\" min=\"0\" max=\"100\" aria-hidden=\"true\"></meter>\n <span rst-meter-num>700/500</span>\n</span>" |
| 91 | if got != want { |
| 92 | t.Errorf("got\n%s\nwant\n%s", got, want) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // A hidden input renders nothing, so the space beside it shows only if |
| 97 | // something visible shares its line. At the head of a form, before the |
| 98 | // first thing drawn, each one gets a line. |
| 99 | func TestFormatPutsSilentElementsOnLinesOfTheirOwn(t *testing.T) { |
| 100 | got := Format(formatCases[6]) |
| 101 | want := "<form rst-form-actions method=\"post\">\n" + |
| 102 | " <input type=\"hidden\" name=\"csrf\" value=\"t\">\n" + |
| 103 | " <input type=\"hidden\" name=\"id\" value=\"1\">\n" + |
| 104 | " <a rst-btn href=\"/p\">Cancel</a>\n" + |
| 105 | " <button rst-btn=\"danger\">Delete</button>\n" + |
| 106 | "</form>" |
| 107 | if got != want { |
| 108 | t.Errorf("got\n%s\nwant\n%s", got, want) |
| 109 | } |
| 110 | // In a sentence, a hidden input between two words keeps the line it |
| 111 | // is on: a break there would be a space between them. |
| 112 | in := `<p>one<input type="hidden" name="a">two</p>` |
| 113 | if got := Format(in); got != in { |
| 114 | t.Errorf("a hidden input inside running text was broken out:\n%s", got) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // An element with nothing in it stays with nothing in it: :empty is a |
| 119 | // selector tokens.css uses, and a line break would stop it matching. |
| 120 | func TestFormatLeavesAnEmptyElementEmpty(t *testing.T) { |
| 121 | for _, in := range []string{ |
| 122 | `<div rst-form-foot><span rst-spin aria-hidden="true"></span></div>`, |
| 123 | `<footer rst-shell-foot></footer>`, |
| 124 | `<div rst-lrow="head"><span>Order</span><span></span></div>`, |
| 125 | } { |
| 126 | out := Format(in) |
| 127 | for _, empty := range []string{`<span rst-spin aria-hidden="true"></span>`, `<footer rst-shell-foot></footer>`, `<span></span>`} { |
| 128 | if strings.Contains(in, empty) && !strings.Contains(out, empty) { |
| 129 | t.Errorf("an empty element got content:\n%s", out) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // A <pre> or <textarea> whose content looks like tags, |
| 136 | // an svg, and an attribute value holding a '>' all pass through byte |
| 137 | // for byte. |
| 138 | func TestFormatLeavesRawZonesAndQuotedBrackets(t *testing.T) { |
| 139 | for _, raw := range []string{ |
| 140 | "<pre> <div>\n not a tag </div>\n</pre>", |
| 141 | "<textarea name=\"n\"><p>typed</p>\n </textarea>", |
| 142 | `<svg viewBox="0 0 2 2"><g><path d="M0 0"/></g></svg>`, |
| 143 | } { |
| 144 | in := "<div>" + raw + "</div>" |
| 145 | out := Format(in) |
| 146 | if !strings.Contains(out, raw) { |
| 147 | t.Errorf("a raw zone changed:\n in: %s\nout: %s", in, out) |
| 148 | } |
| 149 | } |
| 150 | in := `<div data-tip="a > b"><p title='x <div> y'>t</p></div>` |
| 151 | out := Format(in) |
| 152 | for _, v := range []string{`data-tip="a > b"`, `title='x <div> y'`} { |
| 153 | if !strings.Contains(out, v) { |
| 154 | t.Errorf("an attribute value changed: %s", out) |
| 155 | } |
| 156 | } |
| 157 | if strings.Count(out, "\n") != 2 { |
| 158 | t.Errorf("a '>' inside a quoted value was taken for the end of a tag:\n%s", out) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // A select sits in its line like a word: the space after it shows, so |
| 163 | // the formatter may lay out its option list, where nothing between two |
| 164 | // options renders, but must not break around the select itself. |
| 165 | func TestFormatKeepsASelectInItsLine(t *testing.T) { |
| 166 | in := `<p>Pick <select><option>A</option></select><span>B</span></p>` |
| 167 | if got := Format(in); !strings.Contains(got, "Pick <select>") || !strings.Contains(got, "</select><span>B</span>") { |
| 168 | t.Errorf("a select in running text was broken out:\n%s", got) |
| 169 | } |
| 170 | got := Format(`<div rst-field><select name="s"><optgroup label="E"><option>A</option><option>B</option></optgroup></select></div>`) |
| 171 | want := "<div rst-field>\n <select name=\"s\">\n <optgroup label=\"E\">\n <option>A</option>\n <option>B</option>\n </optgroup>\n </select>\n</div>" |
| 172 | if got != want { |
| 173 | t.Errorf("got\n%s\nwant\n%s", got, want) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // Under white-space: pre and its kin a line break is a line break and |
| 178 | // an indent is spaces, whatever the container. An element that says so |
| 179 | // in its style attribute is left byte for byte, its children included, |
| 180 | // as a <pre> is. |
| 181 | func TestFormatLeavesWhitespacePreservingStylesAlone(t *testing.T) { |
| 182 | for _, style := range []string{"white-space:pre", "white-space: pre-wrap", "white-space:pre-line", "white-space: break-spaces", "white-space-collapse: preserve"} { |
| 183 | button := `<button rst-btn style="` + style + `">Pay <em>now</em></button>` |
| 184 | got := Format(`<div rst-form-foot>` + button + `<a rst-btn href="/">Back</a></div>`) |
| 185 | if !strings.Contains(got, button) { |
| 186 | t.Errorf("%s: the button's content was reformatted:\n%s", style, got) |
| 187 | } |
| 188 | } |
| 189 | // nowrap collapses whitespace like normal, so it changes nothing. |
| 190 | got := Format(`<div rst-form-foot><button rst-btn style="white-space: nowrap"><span rst-spin></span>Pay</button></div>`) |
| 191 | if !strings.Contains(got, "\n Pay\n") { |
| 192 | t.Errorf("white-space: nowrap stopped the formatter:\n%s", got) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // HTML allows whitespace either side of an attribute's =, and the |
| 197 | // formatter's reading of a tag has to see the attribute the browser |
| 198 | // sees: style = "white-space:pre" preserves whitespace exactly as |
| 199 | // style="white-space:pre" does. |
| 200 | func TestFormatReadsAnAttributeWithSpacesAroundItsEquals(t *testing.T) { |
| 201 | button := "<button rst-btn style = \"white-space:pre\">Pay <em>now</em></button>" |
| 202 | got := Format(`<div rst-form-foot>` + button + `<a rst-btn href="/">Back</a></div>`) |
| 203 | if !strings.Contains(got, button) { |
| 204 | t.Errorf("a style written with spaces around = was not seen:\n%s", got) |
| 205 | } |
| 206 | attrs := attrsOf("<input type =\n'hidden' name\t= n value=v checked>") |
| 207 | for name, want := range map[string]string{"type": "hidden", "name": "n", "value": "v", "checked": ""} { |
| 208 | if got, ok := attrs[name]; !ok || got != want { |
| 209 | t.Errorf("attrsOf: %s = %q (present %v), want %q", name, got, ok, want) |
| 210 | } |
| 211 | } |
| 212 | if len(attrs) != 4 { |
| 213 | t.Errorf("attrsOf read %d attributes, want 4: %v", len(attrs), attrs) |
| 214 | } |
| 215 | } |
| 216 | |