| 1000 |
1000 |
|
}
|
| 1001 |
1001 |
|
|
| 1002 |
1002 |
|
#[test]
|
| 1003 |
|
- |
fn every_class_this_renderer_emits_is_one_makeover_defines() {
|
|
1003 |
+ |
fn the_ssh_keys_tab_gaps_stay_shut() {
|
| 1004 |
1004 |
|
// The third gap the SSH-keys tab found. A described screen was emitting
|
| 1005 |
1005 |
|
// `act`, `tone-danger` and `chip-latched`, none of which makeover has ever
|
| 1006 |
1006 |
|
// defined, so a described control rendered as unstyled text next to a
|
| 1969 |
1969 |
|
assert!(html.contains(""hi""), "{html}");
|
| 1970 |
1970 |
|
assert!(html.contains("&"), "{html}");
|
| 1971 |
1971 |
|
}
|
|
1972 |
+ |
|
|
1973 |
+ |
/// A screen exercising every [`Node`] variant and every [`RegionKind`].
|
|
1974 |
+ |
///
|
|
1975 |
+ |
/// Written out rather than derived, for the reason `makeover-webview`'s own
|
|
1976 |
+ |
/// `part_class` is written out: both enums are `#[non_exhaustive]`-shaped in
|
|
1977 |
+ |
/// practice and there is nothing to iterate. A variant added upstream and not
|
|
1978 |
+ |
/// added here emits classes this file never sees, which is the one way the
|
|
1979 |
+ |
/// check below can be quietly weakened. Grep this function when adding a node.
|
|
1980 |
+ |
fn every_kind_of_screen() -> Vec<String> {
|
|
1981 |
+ |
let mut htmls = Vec::new();
|
|
1982 |
+ |
|
|
1983 |
+ |
for kind in [
|
|
1984 |
+ |
RegionKind::Band,
|
|
1985 |
+ |
RegionKind::Sidebar,
|
|
1986 |
+ |
RegionKind::Pane,
|
|
1987 |
+ |
RegionKind::Split,
|
|
1988 |
+ |
RegionKind::TabGroup,
|
|
1989 |
+ |
RegionKind::Modal,
|
|
1990 |
+ |
] {
|
|
1991 |
+ |
htmls.push(render(
|
|
1992 |
+ |
&Screen::list_detail("Everything", false)
|
|
1993 |
+ |
.saying(Node::banner(layout::Tone::Warning, "heads up"))
|
|
1994 |
+ |
.saying(Node::toast(layout::Tone::Success, "saved"))
|
|
1995 |
+ |
.with(Slot::new("region", kind).with(Node::text("in a region"))),
|
|
1996 |
+ |
));
|
|
1997 |
+ |
}
|
|
1998 |
+ |
htmls.push(render(&Screen::sidebar_content("Everything").with(
|
|
1999 |
+ |
Slot::bespoke("bespoke", "map").with(Node::text("beside a fill")),
|
|
2000 |
+ |
)));
|
|
2001 |
+ |
htmls.push(render(&Screen::list_detail("Tabbed", true)));
|
|
2002 |
+ |
|
|
2003 |
+ |
let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger);
|
|
2004 |
+ |
for node in [
|
|
2005 |
+ |
Node::page("A page"),
|
|
2006 |
+ |
Node::section("A section"),
|
|
2007 |
+ |
Node::text("plain"),
|
|
2008 |
+ |
Node::rich("**bold** and a [link](https://example.com)"),
|
|
2009 |
+ |
Node::act("Save", Action::post("/save")),
|
|
2010 |
+ |
Node::Token(Tag::badge("Paid").tone(layout::Tone::Success)),
|
|
2011 |
+ |
Node::Token(Tag::chip("Open", Action::get("/tasks?open=1")).latched(true)),
|
|
2012 |
+ |
Node::banner(layout::Tone::Danger, "it broke"),
|
|
2013 |
+ |
Node::toast(layout::Tone::Info, "it saved"),
|
|
2014 |
+ |
Node::empty("nothing here").offering(acts()),
|
|
2015 |
+ |
Node::failed("it broke").offering(acts()),
|
|
2016 |
+ |
Node::field(Field::new(layout::FieldKind::Text, "name", "Name").required()),
|
|
2017 |
+ |
Node::field(Field::new(layout::FieldKind::Secret, "pw", "Password").error("too short")),
|
|
2018 |
+ |
Node::field(
|
|
2019 |
+ |
Field::new(layout::FieldKind::Checkbox, "live", "Live").changes(Action::post("/live")),
|
|
2020 |
+ |
),
|
|
2021 |
+ |
Node::field(Field::select(
|
|
2022 |
+ |
"size",
|
|
2023 |
+ |
"Size",
|
|
2024 |
+ |
vec![Choice::plain("small"), Choice::new("l", "large")],
|
|
2025 |
+ |
)),
|
|
2026 |
+ |
Node::field(Field::radio(
|
|
2027 |
+ |
"mode",
|
|
2028 |
+ |
"Mode",
|
|
2029 |
+ |
vec![Choice::plain("one"), Choice::plain("two")],
|
|
2030 |
+ |
)),
|
|
2031 |
+ |
Node::Form {
|
|
2032 |
+ |
action: Action::post("/new"),
|
|
2033 |
+ |
submit: "Create".into(),
|
|
2034 |
+ |
fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
|
|
2035 |
+ |
},
|
|
2036 |
+ |
Node::list([Row::new("fw13")
|
|
2037 |
+ |
.secondary("a second line")
|
|
2038 |
+ |
.meta("2 days ago")
|
|
2039 |
+ |
.token(Tag::badge("Active"))
|
|
2040 |
+ |
.meter(Meter::new(3, 10))
|
|
2041 |
+ |
.activate(Action::get("/keys/7"))
|
|
2042 |
+ |
.act(acts())])
|
|
2043 |
+ |
.and_more(Rest::more(Action::get("/keys?page=2")).remaining(40)),
|
|
2044 |
+ |
Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]),
|
|
2045 |
+ |
Node::Table {
|
|
2046 |
+ |
columns: vec![
|
|
2047 |
+ |
Column::new("Name")
|
|
2048 |
+ |
.width(layout::Width::Fill)
|
|
2049 |
+ |
.priority(layout::Priority::Essential),
|
|
2050 |
+ |
Column::new("Status")
|
|
2051 |
+ |
.width(layout::Width::Content)
|
|
2052 |
+ |
.priority(layout::Priority::Optional),
|
|
2053 |
+ |
],
|
|
2054 |
+ |
rows: vec![
|
|
2055 |
+ |
Cells::new([
|
|
2056 |
+ |
Cell::new("deploy"),
|
|
2057 |
+ |
Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)),
|
|
2058 |
+ |
])
|
|
2059 |
+ |
.activate(Action::get("/runs/1")),
|
|
2060 |
+ |
Cells::new([Cell::new("build"), Cell::acts([acts()])]),
|
|
2061 |
+ |
],
|
|
2062 |
+ |
},
|
|
2063 |
+ |
Node::Meter(Meter::new(7, 10).label("7 of 10")),
|
|
2064 |
+ |
Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]),
|
|
2065 |
+ |
] {
|
|
2066 |
+ |
htmls.push(fragment(&node));
|
|
2067 |
+ |
}
|
|
2068 |
+ |
|
|
2069 |
+ |
for kind in [
|
|
2070 |
+ |
layout::Selector::Tabs,
|
|
2071 |
+ |
layout::Selector::Segmented,
|
|
2072 |
+ |
layout::Selector::Toggle,
|
|
2073 |
+ |
] {
|
|
2074 |
+ |
htmls.push(fragment(&Node::Select {
|
|
2075 |
+ |
kind,
|
|
2076 |
+ |
options: vec![
|
|
2077 |
+ |
(Choice::plain("open"), Some(Action::get("/tasks?open=1"))),
|
|
2078 |
+ |
(Choice::new("done", "Done"), None),
|
|
2079 |
+ |
],
|
|
2080 |
+ |
chosen: Some("open".into()),
|
|
2081 |
+ |
action: Some(Action::get("/tasks")),
|
|
2082 |
+ |
}));
|
|
2083 |
+ |
}
|
|
2084 |
+ |
|
|
2085 |
+ |
htmls
|
|
2086 |
+ |
}
|
|
2087 |
+ |
|
|
2088 |
+ |
/// Every class name the markup carries, from `class="a b c"` attributes.
|
|
2089 |
+ |
///
|
|
2090 |
+ |
/// Column identity classes are dropped. `col-Name` is `column_classes`'s own
|
|
2091 |
+ |
/// output and names the column rather than the vocabulary, so it is data and
|
|
2092 |
+ |
/// there is nothing for a stylesheet to define.
|
|
2093 |
+ |
fn emitted_classes(htmls: &[String]) -> std::collections::BTreeSet<String> {
|
|
2094 |
+ |
let mut names = std::collections::BTreeSet::new();
|
|
2095 |
+ |
for html in htmls {
|
|
2096 |
+ |
let mut rest = html.as_str();
|
|
2097 |
+ |
while let Some(at) = rest.find("class=\"") {
|
|
2098 |
+ |
rest = &rest[at + 7..];
|
|
2099 |
+ |
let end = rest.find('"').expect("the attribute closes");
|
|
2100 |
+ |
for name in rest[..end].split_whitespace() {
|
|
2101 |
+ |
if !name.starts_with("col-") {
|
|
2102 |
+ |
names.insert(name.to_string());
|
|
2103 |
+ |
}
|
|
2104 |
+ |
}
|
|
2105 |
+ |
rest = &rest[end..];
|
|
2106 |
+ |
}
|
|
2107 |
+ |
}
|
|
2108 |
+ |
names
|
|
2109 |
+ |
}
|
|
2110 |
+ |
|
|
2111 |
+ |
/// Classes this renderer emits that no rule in the generated stylesheet names,
|
|
2112 |
+ |
/// because there is nothing for makeover to say about them.
|
|
2113 |
+ |
///
|
|
2114 |
+ |
/// Arrangements and regions are placement, and placement is spacing:
|
|
2115 |
+ |
/// `makeover-geometry`'s question, answered per app in `styles.css`. The rest
|
|
2116 |
+ |
/// are containers holding things makeover styles one by one, or elements that
|
|
2117 |
+ |
/// are already an element before they are a class.
|
|
2118 |
+ |
///
|
|
2119 |
+ |
/// This list is the boundary written down, not a todo. A *component* joining it
|
|
2120 |
+ |
/// is the bug, and the test is what refuses one.
|
|
2121 |
+ |
const BY_DESIGN: &[&str] = &[
|
|
2122 |
+ |
// Arrangements, from `Webview::arrangement_class`.
|
|
2123 |
+ |
"list-detail",
|
|
2124 |
+ |
"list-detail-tabbed",
|
|
2125 |
+ |
"sidebar-content",
|
|
2126 |
+ |
// Regions, from `region_class`.
|
|
2127 |
+ |
"band",
|
|
2128 |
+ |
"bespoke",
|
|
2129 |
+ |
"modal",
|
|
2130 |
+ |
"pane",
|
|
2131 |
+ |
"region",
|
|
2132 |
+ |
"sidebar",
|
|
2133 |
+ |
"split",
|
|
2134 |
+ |
"tabgroup",
|
|
2135 |
+ |
// Containers. Each holds things that carry their own styled classes.
|
|
2136 |
+ |
"figures",
|
|
2137 |
+ |
"form",
|
|
2138 |
+ |
"notices",
|
|
2139 |
+ |
"rest",
|
|
2140 |
+ |
"row",
|
|
2141 |
+ |
"selector",
|
|
2142 |
+ |
// Typography. makeover sets no type scale, so a heading and a paragraph
|
|
2143 |
+ |
// are an `h2` and a `p` before they are anything this crate named.
|
|
2144 |
+ |
"heading",
|
|
2145 |
+ |
"rich",
|
|
2146 |
+ |
"text",
|
|
2147 |
+ |
];
|
|
2148 |
+ |
|
|
2149 |
+ |
/// Classes that name *which* of something, on an element already styled by the
|
|
2150 |
+ |
/// class beside them.
|
|
2151 |
+ |
///
|
|
2152 |
+ |
/// `class="button act-submit"` takes its whole appearance from `button`. The
|
|
2153 |
+ |
/// second name exists so an app can reach the submit button of a form without
|
|
2154 |
+ |
/// reaching every button, and a rule for it upstream would be makeover deciding
|
|
2155 |
+ |
/// that a submit button looks different, which is the app's call.
|
|
2156 |
+ |
///
|
|
2157 |
+ |
/// The row parts are makeover's own `part_class` output. `row_rules` writes a
|
|
2158 |
+ |
/// colour for `Primary`, `Secondary` and `Meta` and deliberately none for these
|
|
2159 |
+ |
/// three: actions carry controls, and tokens and proportions each carry their
|
|
2160 |
+ |
/// own tone, so a colour on the container would fight what is inside it.
|
|
2161 |
+ |
const MODIFIERS: &[&str] = &[
|
|
2162 |
+ |
"act-submit",
|
|
2163 |
+ |
"rest-more",
|
|
2164 |
+ |
"row-actions",
|
|
2165 |
+ |
"row-activate",
|
|
2166 |
+ |
"row-proportion",
|
|
2167 |
+ |
"row-select",
|
|
2168 |
+ |
"row-selected",
|
|
2169 |
+ |
"row-tokens",
|
|
2170 |
+ |
"cell-actions",
|
|
2171 |
+ |
"cell-tokens",
|
|
2172 |
+ |
"field-writes",
|
|
2173 |
+ |
];
|
|
2174 |
+ |
|
|
2175 |
+ |
/// Classes that reach the markup with no rule anywhere, which is a gap rather
|
|
2176 |
+ |
/// than a decision.
|
|
2177 |
+ |
///
|
|
2178 |
+ |
/// Every one is a component: something makeover-layout names, that a described
|
|
2179 |
+ |
/// screen produces, that arrives unstyled. This is the same failure the
|
|
2180 |
+ |
/// SSH-keys tab found, one layer up — the name is not invented here, it is
|
|
2181 |
+ |
/// correct and nothing defines it.
|
|
2182 |
+ |
///
|
|
2183 |
+ |
/// Three separate causes, none of them fixable in this crate alone:
|
|
2184 |
+ |
///
|
|
2185 |
+ |
/// - `form-*`, `has-error`, `visible` and `placeholder-action` are emitted by
|
|
2186 |
+ |
/// `makeover_webview::form` and `::placeholder` and styled by no rule that
|
|
2187 |
+ |
/// crate's own `stylesheet` writes. A field's anatomy is makeover's from end
|
|
2188 |
+ |
/// to end, so both halves are over there.
|
|
2189 |
+ |
/// - `cell-fill` and `cell-keeps` come off `column_classes`, and the rules that
|
|
2190 |
+ |
/// make them mean anything come off `list::narrowing_css`, which needs the
|
|
2191 |
+ |
/// columns and is therefore per-table. Nothing calls it here, so a described
|
|
2192 |
+ |
/// table has no column tracks and no narrowing: every column is content-width
|
|
2193 |
+ |
/// and none of them ever drops. goingson generates its `tables.css` from it in
|
|
2194 |
+ |
/// its own `build.rs`, which is the shape a described table cannot use,
|
|
2195 |
+ |
/// because its columns are known at render time and not at build time.
|
|
2196 |
+ |
/// - `banner` and `toast` are `makeover_layout::Notice`, which the description
|
|
2197 |
+ |
/// layer names and `component_rules` has no section for.
|
|
2198 |
+ |
///
|
|
2199 |
+ |
/// Shrinking this list is the work. Growing it needs a reason written here.
|
|
2200 |
+ |
const GAPS: &[&str] = &[
|
|
2201 |
+ |
"form-checkbox-label",
|
|
2202 |
+ |
"form-error",
|
|
2203 |
+ |
"form-group",
|
|
2204 |
+ |
"form-label",
|
|
2205 |
+ |
"form-radio-group",
|
|
2206 |
+ |
"form-radio-label",
|
|
2207 |
+ |
"has-error",
|
|
2208 |
+ |
"visible",
|
|
2209 |
+ |
"placeholder-action",
|
|
2210 |
+ |
"cell-fill",
|
|
2211 |
+ |
"cell-keeps",
|
|
2212 |
+ |
"banner",
|
|
2213 |
+ |
"toast",
|
|
2214 |
+ |
];
|
|
2215 |
+ |
|
|
2216 |
+ |
#[test]
|
|
2217 |
+ |
fn every_class_this_renderer_emits_is_one_makeover_defines() {
|
|
2218 |
+ |
// The invariant the SSH-keys tab found three counterexamples to, checked
|
|
2219 |
+ |
// by enumeration rather than by remembering the three. `act`, `tone-danger`
|
|
2220 |
+ |
// and `chip-latched` were each a name this renderer made up, and each one
|
|
2221 |
+ |
// rendered a described control as unstyled text beside a hand-written one
|
|
2222 |
+ |
// that had a rule. A fourth is a matter of time without this.
|
|
2223 |
+ |
let css = makeover_webview::stylesheet(&Emit::default());
|
|
2224 |
+ |
let emitted = emitted_classes(&every_kind_of_screen());
|
|
2225 |
+ |
|
|
2226 |
+ |
// Whole-name matching. `.row` is in the stylesheet and `.row-primary`
|
|
2227 |
+ |
// starts with it, so a substring search would call every misspelling styled.
|
|
2228 |
+ |
let styled = |name: &str| {
|
|
2229 |
+ |
css.match_indices(&format!(".{name}")).any(|(at, found)| {
|
|
2230 |
+ |
css[at + found.len()..]
|
|
2231 |
+ |
.chars()
|
|
2232 |
+ |
.next()
|
|
2233 |
+ |
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
|
|
2234 |
+ |
})
|
|
2235 |
+ |
};
|
|
2236 |
+ |
|
|
2237 |
+ |
let unstyled: Vec<&str> = emitted
|
|
2238 |
+ |
.iter()
|
|
2239 |
+ |
.map(String::as_str)
|
|
2240 |
+ |
.filter(|name| !styled(name))
|
|
2241 |
+ |
.collect();
|
|
2242 |
+ |
|
|
2243 |
+ |
let mut accounted: Vec<&str> = [BY_DESIGN, MODIFIERS, GAPS].concat();
|
|
2244 |
+ |
accounted.sort_unstable();
|
|
2245 |
+ |
assert_eq!(
|
|
2246 |
+ |
accounted.len(),
|
|
2247 |
+ |
accounted
|
|
2248 |
+ |
.iter()
|
|
2249 |
+ |
.collect::<std::collections::BTreeSet<_>>()
|
|
2250 |
+ |
.len(),
|
|
2251 |
+ |
"a name is in two of the three lists, which means two answers to one \
|
|
2252 |
+ |
question"
|
|
2253 |
+ |
);
|
|
2254 |
+ |
|
|
2255 |
+ |
assert_eq!(
|
|
2256 |
+ |
unstyled, accounted,
|
|
2257 |
+ |
"a class this renderer emits has no rule and no entry above. If \
|
|
2258 |
+ |
makeover spells it differently, use makeover's spelling -- that is the \
|
|
2259 |
+ |
whole of the SSH-keys bug. Otherwise put it in BY_DESIGN, MODIFIERS or \
|
|
2260 |
+ |
GAPS with the reason, and note that GAPS is work rather than a \
|
|
2261 |
+ |
decision."
|
|
2262 |
+ |
);
|
|
2263 |
+ |
}
|
|
2264 |
+ |
|
|
2265 |
+ |
#[test]
|
|
2266 |
+ |
fn a_class_prefix_reaches_the_markup_the_way_it_reaches_the_stylesheet() {
|
|
2267 |
+ |
// The prefix is one setting shared by two emitters, and the failure is
|
|
2268 |
+ |
// silent in the same way: a prefixed app whose renderer forgot the prefix
|
|
2269 |
+ |
// on one element gets a stylesheet that matches everything except that
|
|
2270 |
+ |
// element.
|
|
2271 |
+ |
let emit = Emit {
|
|
2272 |
+ |
class_prefix: "mk-",
|
|
2273 |
+ |
..Emit::default()
|
|
2274 |
+ |
};
|
|
2275 |
+ |
let html = Webview::new()
|
|
2276 |
+ |
.with_emit(emit)
|
|
2277 |
+ |
.fragment(&Node::list([Row::new("fw13").token(Tag::badge("Active"))]));
|
|
2278 |
+ |
|
|
2279 |
+ |
assert!(html.contains("class=\"mk-row\""), "{html}");
|
|
2280 |
+ |
assert!(html.contains("mk-row-primary"), "{html}");
|
|
2281 |
+ |
assert!(html.contains("mk-badge"), "{html}");
|
|
2282 |
+ |
assert!(!html.contains("\"row\""), "{html}");
|
|
2283 |
+ |
}
|