//! Tests for [`super`]. use super::*; use makeover_layout::Edge; #[test] fn every_fallback_class_is_one_a_checker_knows_about() { // The obligation ROW_PART_CLASSES carries, for the same reason: a class // this crate can write and the vocabulary list does not carry is // invisible to the dead-vocabulary seal and to the overlap check both. for fallback in [ Fallback::Wrap, Fallback::Stack, Fallback::Shed, Fallback::Menu, ] { assert!( RUN_CLASSES.contains(&fallback_class(fallback)), "{fallback:?} is missing from RUN_CLASSES" ); } let names = crate::vocabulary::names(&Emit::default()); for name in RUN_CLASSES { assert!(names.contains(*name), "{name} is not in the vocabulary"); } } #[test] fn a_run_gives_every_member_a_floor_it_cannot_be_squeezed_below() { // The whole of what stops the overlap, and it is not a fallback: it // applies to every run whatever the group declared. flexbox's default // min-width is auto, which lets an item be compressed below its own // content in a nowrap row, and that is how a toolbar is drawn over a // tab strip even with nothing out of flow. let css = run_rules(&Emit::default()); assert!(css.contains(".run > * {\n min-width: min-content;\n}")); // No number anywhere in it. The minimum is derived by the browser from // what the members contain, which is the ruling's own requirement. assert!(!css.contains("px")); assert!(!css.contains("rem")); assert!(!css.contains("@media")); } #[test] fn a_member_that_asks_to_fill_absorbs_what_is_left() { // The second half of what a column says, reaching a row of regions // (quasicoherent `cf981aaa`). A zero basis and not `auto`, because equal // division between several fills is `Width::Fill`'s own stated rule and // `auto` divides the leftovers in proportion to the contents instead. let css = run_rules(&Emit::default()); assert!(css.contains(".run > [data-width=\"fill\"] {\n flex: 1 1 0;\n}")); // The floor is not overridden. A fill that could shrink under its own // contents would overlap its neighbour, which is the rule this whole // mechanism exists to keep. assert!(!css.contains("min-width: 0")); // A member that says nothing gets nothing: content is what a flex item // with the floor and no grow already is. assert!(!css.contains("data-width=\"content\"")); } #[test] fn room_is_never_asked_of_the_viewport() { // The 913 case: a window in SizeClass::Expanded holding a group out of // room. A viewport query answers about the window and would be wrong // about the group, which is why the table's @media walk is not the // precedent this follows. let css = run_rules(&Emit::default()); for size in [SizeClass::Compact, SizeClass::Medium] { assert!(!css.contains(&size.media_condition())); } } #[test] fn every_fallback_lands_as_a_class_and_an_unknown_one_lands_plainly() { let css = run_rules(&Emit::default()); for fallback in [ Fallback::Wrap, Fallback::Stack, Fallback::Shed, Fallback::Menu, ] { let class = fallback_class(fallback); assert!(css.contains(&format!(".{class} {{")), "{class} unemitted"); } // Stack is the one that also says what a member does with the line it // took, which is what separates it from wrapping. assert!(css.contains(".run-stack > * {\n flex: 1 1 max-content;\n}")); } #[test] fn the_emitted_bevel_matches_what_the_apps_already_hand_write() { // Balanced Breakfast's styles.css, verbatim. Adoption has to be a // deletion, not a redesign, or nobody will take it. let opts = Emit::default(); assert_eq!( bevel_shadow(Bevel::Raised, &opts), "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)" ); assert_eq!( bevel_shadow(Bevel::Inset, &opts), "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)" ); } #[test] fn no_colour_ever_reaches_the_output() { let css = stylesheet(&Emit::default()); assert!(!css.contains('#'), "a hex literal escaped into the CSS"); assert!( !css.contains("rgb"), "a colour function escaped into the CSS" ); // Every colour is named, never resolved. assert!(css.contains("var(--surface-raised)")); assert!(css.contains("var(--bevel-light)")); } #[test] fn a_well_falls_back_through_css_rather_than_through_rust() { assert_eq!( fill_var(Fill::Well), "var(--surface-well, var(--surface-page))" ); // Nothing else needs one. assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)"); assert_eq!(fill_var(Fill::Page), "var(--surface-page)"); } #[test] fn raised_and_well_do_not_collapse_onto_each_other() { let css = depth_rules(&Emit::default()); assert!(css.contains(".raised {")); assert!(css.contains(".well {")); assert!(css.contains("var(--bevel-raised)")); assert!(css.contains("var(--bevel-inset)")); } /// The cast shadow is composed here from the tone `makeover` derives, so /// neither crate has to hold the other's numbers. /// /// It is a `:root` property and deliberately not a depth class. There is no /// `Depth::Overlay` in the description layer, and adding one would be a /// claim about what a screen means rather than about how it is painted; /// until something asks for it, a consumer names the property on the rule /// for the menu or the toast it already has. #[test] fn the_cast_shadow_is_a_root_property_not_a_depth() { let css = bevel_properties(&Emit::default()); assert!(css.contains("--elevation-overlay:")); assert!(css.contains("var(--elevation)")); assert!( !depth_rules(&Emit::default()).contains("elevation"), "elevation is not a depth class" ); } #[test] fn the_cascade_carries_the_pressed_state() { let css = surface_rules(&Emit::default()); // The one thing this renderer gets free that the other two resolve by // hand, eighteen call sites deep in audiofiles' case. Asserted on a // named surface: pressing belongs to the control, not to the depth. assert!(css.contains(".card:active {")); assert!(css.contains(".button:active {")); } #[test] fn the_depth_class_is_a_surface_and_not_a_control() { let css = depth_rules(&Emit::default()); // The static surface the vocabulary was missing. Sixteen goingson // elements wore .card and cancelled its hover and press to get this, // because a raised object that is not pressable had no other spelling. for state in [":hover", ":active", ":focus-visible", ":disabled"] { assert!( !css.contains(&format!(".raised{state}")), "the depth class claimed {state}: {css}" ); } assert!(css.contains("var(--bevel-raised)"), "still raised: {css}"); } #[test] fn pressing_moves_the_fill_and_not_only_the_edge() { // The decision-1 guard, and the regression that mattered: emitting the // bevel flip alone is what left goingson hand-writing `background: // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none // of the three could be deleted. let pressed = interactive_rules("button", Depth::Raised, &Emit::default()); assert!(pressed.contains(".button:active {")); assert!( pressed.contains("background: var(--surface-well, var(--surface-page))"), "pressed dropped its fill: {pressed}" ); assert!(pressed.contains("box-shadow: var(--bevel-inset)")); } #[test] fn pressed_takes_its_fill_from_the_description_not_from_the_app() { // goingson presses to --surface-sunken. The description says a pressed // raised region reads as a well, and makeover says outright that // surface-sunken cannot serve as one, so the app is the thing that // moves. // // Scoped to the pressed rules rather than to the whole sheet: since // makeover-layout 0.3.0 an unchosen tab is legitimately // --surface-sunken, so the token appearing somewhere in the output no // longer means the app's choice leaked in. let css = stylesheet(&Emit::default()); let mut checked = 0; for rule in css.split("}\n") { if !rule.contains(":active") { continue; } checked += 1; assert!( !rule.contains("surface-sunken"), "a pressed rule took the app's fill: {rule}" ); } assert!(checked > 0, "no pressed rules found to check"); assert_eq!( Depth::Raised.pressed().fill(), Some(Fill::Well), "the description changed under us" ); } #[test] fn the_whole_stylesheet_is_emitted_in_the_family_layer() { // The point of 0.11.0. Unlayered normal declarations outrank every // named layer, so an app declaring `@layer base, components` loses // every rule it owns to this file until this file is layered too. let css = stylesheet(&Emit::default()); assert!(css.contains(&format!("@layer {CSS_LAYER} {{"))); // Exactly one layer block, and nothing outside it but the banner. assert_eq!(css.matches("@layer").count(), 2, "banner names it once"); let opened = css.find("@layer makeover {").expect("layer opens"); for (i, line) in css.lines().enumerate() { let before_layer = css.lines().take(i).map(str::len).sum::() < opened; if before_layer || line.is_empty() { continue; } assert!( line.starts_with(" ") || line == "}" || line.starts_with(" "), "line outside the layer: {line:?}" ); } } #[test] fn the_generated_sheet_carries_no_trailing_whitespace() { // A checked-in generated file that a formatter wants to rewrite is a // diff every time somebody saves it. let css = stylesheet(&Emit::default()); for (i, line) in css.lines().enumerate() { assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}"); } } #[test] fn the_banner_tells_an_app_how_to_order_the_layer() { // Without a declared order the layer's position depends on which // generated file the browser sees first, which is not a contract. let css = stylesheet(&Emit::default()); assert!(css.contains("@layer makeover, base, components, responsive;")); // And the banner is outside the layer, not a rule inside it. assert!(css.starts_with("/* Generated by makeover-webview")); } #[test] fn the_banner_names_the_emitter_so_a_stale_pin_is_visible_on_sight() { // A consumer whose lockfile pins an old version gets a well-formed // sheet with components missing and no error. balanced_breakfast ran // on 657 bytes from a 0.1.0 emitter while its manifest asked for // 0.5.1, and the only way it surfaced was diffing two apps' generated // files. The version and the count are what the file says instead. let css = stylesheet(&Emit::default()); let banner = css.lines().next().unwrap(); assert!( banner.contains(VERSION), "{banner} does not name the emitter" ); let classes = vocabulary::classes_in_css(&css).len(); assert!(classes > 0); assert!( banner.contains(&format!("{classes} classes")), "{banner} does not carry the class count" ); } #[test] fn a_primitive_owns_every_state_it_implies() { // The whole point of 0.10.0. Anything emitting a hover rule owes the // other three, or the consuming app supplies them by out-specifying a // rule it does not own: 19 such rules in goingson, 21 in the MNW // server, and three focus rings that do not match. let css = stylesheet(&Emit::default()); for selector in ["button", "card", "chip", "tab", "segment", "toggle"] { assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}"); assert!( css.contains(&format!(".{selector}:active {{")), "{selector}" ); assert!( css.contains(&format!(".{selector}:focus-visible {{")), "{selector} has no focus ring" ); assert!( css.contains(&format!(".{selector}:disabled,")), "{selector} has no disabled state" ); } } #[test] fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() { // A text field does not light up under the pointer, so it gets the two // states it has and not the two it does not. let css = stylesheet(&Emit::default()); assert!(css.contains(".field:focus-visible {")); assert!(css.contains(".field:disabled,")); assert!(!css.contains(".field:hover {")); assert!(!css.contains(".field:active {")); } #[test] fn disabled_is_emitted_after_hover_so_source_order_settles_it() { // Every one of these selectors is specificity (0,2,0), so nothing but // order decides which wins. A disabled button taking the hover fill is // the exact bug goingson's `.button:disabled:hover` was written to fix, // and the reason it had to reach (0,3,0) to do it. let css = interactive_rules("button", Depth::Raised, &Emit::default()); let hover = css.find(":hover").expect("hover"); let active = css.find(":active").expect("active"); let focus = css.find(":focus-visible").expect("focus"); let disabled = css.find(":disabled").expect("disabled"); assert!(hover < active && active < focus && focus < disabled); // And it restores the surface, or the hover fill survives underneath. let tail = &css[disabled..]; assert!(tail.contains("background: var(--surface-raised)")); } #[test] fn a_flat_control_takes_its_hover_fill_back_when_it_stops_answering() { // The same contest one depth over, and the half `depth_declarations` // could not state. Flat declares neither axis, so before 0.68.0 the // disabled rule won on source order with nothing to say and the hover // surface stayed under a control that had stopped answering. Reaches // both facet arms and a suggestion entry. for depth in [Depth::Flat, Depth::Sunken, Depth::Overlay] { let css = disabled_rule("x", depth); assert!(css.contains("box-shadow"), "{depth:?}: {css}"); } let flat = disabled_rule("x", Depth::Flat); assert!(flat.contains("background: none;"), "{flat}"); assert!(flat.contains("box-shadow: none;"), "{flat}"); // Every rule the caller emits states both axes now, so whichever wins // the source-order contest leaves nothing of the one below it. let css = interactive_rules("x", Depth::Flat, &Emit::default()); let disabled = css.find(":disabled").expect("disabled"); assert!(css[disabled..].contains("background: none;"), "{css}"); } #[test] fn a_disabled_state_reaches_things_that_cannot_be_disabled() { // `:disabled` matches form elements only, and a chip is a div. Keying // on the ARIA attribute too is the pattern the invalid field already // set: one fact, read by the styling and the accessibility tree alike. let css = disabled_rule("chip", Depth::Raised); assert!(css.contains(".chip:disabled,")); assert!(css.contains(".chip[aria-disabled=\"true\"]")); assert!(css.contains("cursor: not-allowed")); } #[test] fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() { // `outline` has its own property, so unlike the invalid ring there is // no bevel to restate beside it and nothing to keep in agreement. let opts = Emit::default(); let css = focus_rule("button", Depth::Raised, &opts); assert!(css.contains("outline: 2px solid var(--focus-ring)")); assert!(!css.contains("box-shadow"), "the ring restated the bevel"); } #[test] fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() { // One ring, placed by depth. The offset comes off `Depth::bevel` and // not off a per-component choice, which is what gave three apps three // different rings. let opts = Emit::default(); assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)")); assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px")); // Nothing to sit inside of, so it sits outside. assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px")); // And the ring is not the bevel. Reusing border_width emitted a 1px // ring that every consumer had already overridden. assert_ne!(opts.focus_width, opts.border_width); } #[test] fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() { // goingson's section 60 exists only to take back the hover state this // crate handed it. Gating at the source is what deletes that section // in all three apps rather than having each fight for it. let css = stylesheet(&Emit::default()); let condition = format!("@media {}", Density::Pointer.media_condition()); assert!(css.contains(&condition)); // What is gated is every hover state the surfaces carry. The row's // actions used to be the other half of this test and are not gated any // more, because they are not hidden any more: a rule that reveals // nothing needs no capability answer. let gated: Vec<&str> = css.lines().filter(|line| line.contains(":hover")).collect(); assert!(!gated.is_empty(), "{css}"); for line in gated { let indent = line.len() - line.trim_start().len(); assert!(indent > 4, "an ungated hover rule: {line}"); } assert!(!css.contains(".row:hover"), "{css}"); } #[test] fn the_capability_answer_is_asked_for_and_not_assumed() { // Both halves come from the crates that own them. If `makeover-touch` // ever says a fingertip has hover, this stops gating on its own. assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact)); assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact)); assert_eq!(hover_condition(), Some(Density::Pointer.media_condition())); // And the size class passed to that call is not a claim about width. assert!(Affordance::Hover.reads_density()); for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] { assert!(!Affordance::Hover.available(Density::Touch, size)); } } #[test] fn hover_resolves_against_the_token_makeover_already_derives() { let css = interactive_rules("card", Depth::Raised, &Emit::default()); assert!(css.contains(".card:hover {")); assert!(css.contains("background: var(--hover-surface)")); // Not the app's choice, which was --surface-overlay. assert!(!css.contains("surface-overlay")); } #[test] fn a_badge_gets_no_edge_and_no_fill() { // Decision 2, and the one visible redesign in phase A. Token::Badge is // Flat: an edge on a label says it can be pressed. let css = token_rules(&Emit::default()); let badge = css .lines() .skip_while(|l| !l.starts_with(".badge {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}"); assert!(!badge.contains("background"), "badge kept a fill: {badge}"); assert_eq!(Token::Badge.depth(false), Depth::Flat); assert_eq!(Token::Badge.depth(true), Depth::Flat); } #[test] fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() { let css = token_rules(&Emit::default()); // Neutral is the absence of a status, not a status named "none". assert!(css.contains(".badge {\n color: var(--content-muted);")); assert!(!css.contains("data-tone=\"content-muted\"")); for tone in ["info", "success", "warning", "danger"] { assert!( css.contains(&format!(".badge[data-tone=\"{tone}\"]")), "missing tone {tone}" ); assert!(css.contains(&format!("color: var(--{tone})"))); } } #[test] fn a_chip_is_raised_and_latches_into_a_well() { let css = token_rules(&Emit::default()); assert!(css.contains(".chip {")); assert!(css.contains(".chip.latched {")); assert!(css.contains(".chip:active {")); // The whole difference from a badge: it answers a click. assert!(Token::Chip { removable: false }.interactive()); assert!(!Token::Badge.interactive()); } #[test] fn only_a_tab_comes_forward_when_chosen() { // The folder semantic. Collapsing the three selectors would lose it. let css = selector_rules(&Emit::default()); assert!(css.contains(".tab.chosen {")); assert!(css.contains(".segment.chosen {")); assert!(css.contains(".toggle.chosen {")); assert_eq!(Selector::Tabs.chosen(), Depth::Raised); assert_eq!(Selector::Segmented.chosen(), Depth::Well); assert_eq!(Selector::Toggle.chosen(), Depth::Well); let tab = css .lines() .skip_while(|l| !l.starts_with(".tab.chosen {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!( tab.contains("var(--bevel-raised)"), "tab was held in: {tab}" ); } #[test] fn an_unchosen_tab_recedes_without_looking_picked() { let css = selector_rules(&Emit::default()); // Recessed by colour and given no edge. An edge would make every option // look picked; flat would leave the chosen one nothing to come forward // from, which is the gap makeover-layout 0.3.0 closed. assert!( css.contains(".tab {\n background: var(--surface-sunken);\n}"), "unchosen tab is not recessed: {css}" ); assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken); assert!(css.contains(".tab:hover {")); } #[test] fn a_segment_stands_up_so_the_chosen_one_can_be_held_in() { // The inverse of the tab, and why the three selectors are not one // member with a flag. let css = selector_rules(&Emit::default()); assert!(css.contains(".segment {\n background: var(--surface-raised);")); assert_eq!(Selector::Segmented.unchosen(), Depth::Raised); assert_eq!(Selector::Segmented.chosen(), Depth::Well); } #[test] fn a_rows_actions_are_shown_at_rest() { let css = row_rules(&Emit::default()); // The hover reveal is gone, and with it every escape it needed. What // it hid was hidden from pointer users alone, who are the ones // scanning a list to learn what can be done to a row. assert!(!css.contains("opacity"), "{css}"); assert!(!css.contains("pointer-events"), "{css}"); assert!(!css.contains(":hover"), "{css}"); assert!(!css.contains(":focus-within"), "{css}"); // Nor is it hidden any other way. `display: none` would reflow the row // and `visibility: hidden` would take the actions out of the focus // order; the point is that neither is reached for. assert!(!css.contains("display: none"), "{css}"); assert!(!css.contains("visibility:"), "{css}"); } #[test] fn a_figures_tone_lands_on_the_delta_when_there_is_one() { // 0.13.0. The delta is the part that reads as good or bad; the number // itself is an ordinary fact. A figure with no delta has nowhere else to // put the colour, so the value takes it, and `:has` is what lets one // attribute mean both without the emitter choosing an element. let css = stylesheet(&Emit::default()); assert!( css.contains(".figure[data-tone=\"success\"] > .figure-change"), "{css}" ); assert!( css.contains(".figure[data-tone=\"success\"]:not(:has(> .figure-change)) > .figure-value"), "{css}" ); // The caption is the noun and never takes the tone. assert!( !css.contains("[data-tone=\"success\"] > .figure-caption"), "{css}" ); } #[test] fn the_three_text_parts_take_their_intents_and_actions_inherits() { let css = row_rules(&Emit::default()); assert!(css.contains(".row-primary {\n color: var(--content);")); assert!(css.contains(".row-secondary {\n color: var(--content-secondary);")); assert!(css.contains(".row-meta {\n color: var(--content-muted);")); // Actions carry controls, not text. Pinning the colour it would inherit // anyway is louder than saying nothing. assert!(!css.contains(".row-actions {\n color:")); } #[test] fn the_token_strip_takes_no_colour_of_its_own() { // makeover-layout 0.9.0. A token carries its own tone, so a colour on // the strip would be a rule fighting the things sitting in it -- the // same reasoning as actions, reached for a different reason. let css = row_rules(&Emit::default()); assert!(!css.contains(".row-tokens {\n color:")); } #[test] fn an_unknown_row_part_renders_plainly_rather_than_failing_to_build() { // What `#[non_exhaustive]` bought and what it cost. `part_class` can no // longer be exhaustive, so a member added upstream lands as a bare // class with no rule instead of stopping the build. Asserting the // fallback exists is what keeps it from being written as `unreachable!` // by someone who reads the match as closed. assert_eq!(part_class(RowPart::Tokens), "row-tokens"); assert_eq!(part_class(RowPart::Meta), "row-meta"); } #[test] fn a_link_takes_the_action_colour_the_theme_actually_defines() { // `--action-primary` shipped here for months and no theme has ever // defined it, so every `.link` dropped its colour declaration outright // and fell back to inherited text. Nothing caught it because the sheet // is valid CSS either way; MNW's no-undefined-token lint is what found // it, 2026-08-14. The hover arm two lines below was always `--action-hover`, // which is what makes the typo legible in hindsight. let css = link_rules(&Emit::default()); assert!(css.contains("color: var(--action);")); assert!(!css.contains("--action-primary")); assert!(css.contains("color: var(--action-hover);")); } #[test] fn the_progress_trough_is_a_well() { let css = progress_rules(&Emit::default()); assert!(css.contains(".progress {")); assert!(css.contains("box-shadow: var(--bevel-inset)")); assert!(css.contains(".progress > .progress-fill {")); assert!(css.contains("background: var(--action)")); // A bare `.fill` would catch things that have nothing to do with // progress once the sheet lands unprefixed. assert!(!css.contains("> .fill ")); } #[test] fn a_progress_bar_can_carry_a_tone_and_defaults_to_action() { let css = progress_rules(&Emit::default()); // Untoned is --action, not Tone::Neutral's content-muted: a bar with no // status is still reporting progress, and muted would read as disabled. assert!(css.contains(".progress > .progress-fill {\n background: var(--action);")); assert!(!css.contains("progress-fill {\n color: var(--content-muted)")); for tone in ["info", "success", "warning", "danger"] { assert!( css.contains(&format!(".progress > .progress-fill[data-tone=\"{tone}\"]")), "missing progress tone {tone}" ); } // goingson's two live cases, which is why the tones are emitted at all. assert!(css.contains("[data-tone=\"success\"] {\n background: var(--success);")); assert!(css.contains("[data-tone=\"danger\"] {\n background: var(--danger);")); } #[test] fn no_scrollbar_track_is_emitted() { // Decision 3's negative half. It was on the phase A list and came off; // this is what stops it drifting back in. let css = stylesheet(&Emit::default()); assert!(!css.contains("scrollbar")); assert!(!css.contains("::-webkit")); } #[test] fn an_invalid_field_is_ringed_without_being_lit() { let css = surface_rules(&Emit::default()); assert!(css.contains(".field {")); // The ARIA attribute, not a class: one fact, read by both the visual // and the accessible state, so they cannot drift. assert!(css.contains(".field[aria-invalid=\"true\"] {")); assert!(!css.contains(".field.invalid")); // A flat ring: this edge says "wrong", and a two-tone bevel would have // it say "raised" at the same time. assert!(css.contains("0 0 0 1px var(--danger)")); } #[test] fn an_invalid_field_keeps_the_well_underneath_it() { // box-shadow is not additive. A lone ring replaces the bevel and drops // the well out from under the field, which is what this emitted before // 0.5.0 and is the whole reason the rule composes. let css = surface_rules(&Emit::default()); let invalid = css .lines() .skip_while(|l| !l.starts_with(".field[aria-invalid")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!( invalid.contains("var(--bevel-inset)"), "the well was dropped: {invalid}" ); assert!(invalid.contains("var(--danger)")); } #[test] fn button_and_card_come_out_identical_by_construction() { // The duplication phase A deletes. They are the same composition, so // the only honest way to emit both is from one call. let opts = Emit::default(); let css = surface_rules(&opts); assert_eq!( depth_declarations(Depth::Raised), depth_declarations(Depth::Raised) ); assert!(css.contains(".button {")); assert!(css.contains(".card {")); assert_eq!( interactive_rules("button", Depth::Raised, &Emit::default()).replace("button", "card"), interactive_rules("card", Depth::Raised, &Emit::default()) ); } #[test] fn a_prefix_reaches_the_component_classes_too() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = stylesheet(&opts); for name in [ "mo-button", "mo-card", "mo-field", "mo-badge", "mo-chip", "mo-tab", "mo-row-primary", "mo-progress", "mo-progress-fill", ] { assert!(css.contains(&format!(".{name}")), "unprefixed: {name}"); } // The bare names must be gone entirely, or a prefixed build still // collides with the app's own stylesheet. assert!(!css.contains(".button {")); assert!(!css.contains(".card {")); assert!(!css.contains(".badge {")); } #[test] fn the_class_a_renderer_puts_on_an_option_is_the_one_the_rules_key_off() { // `option_class` is the contract a screen renderer writes markup // against, and the rules below are the other half of it. They come off // one mapping now, so this asserts the mapping is the one that reaches // the stylesheet rather than that two lists still agree. let css = stylesheet(&Emit::default()); for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] { let name = option_class(selector); assert!(css.contains(&format!(".{name} {{")), "{name}: {css}"); assert!(css.contains(&format!(".{name}.chosen {{")), "{name}: {css}"); } assert_eq!(option_class(Selector::Tabs), "tab"); } #[test] fn the_caret_brings_its_own_gap_and_is_the_glyph_the_description_names() { let css = stylesheet(&Emit::default()); // The space is inside the glyph, which is what the other two renderers // write. Emitted bare, every consumer has to add it back, and the // obvious way to add it -- `content` in an app stylesheet, which is // unlayered and so outranks this sheet -- deletes the caret instead. assert!(css.contains("content: \" \\25B2\";"), "{css}"); assert!(css.contains("content: \" \\25BC\";"), "{css}"); assert!(!css.contains("content: \"\\2"), "{css}"); // The arrows this renderer used to draw alone are gone. Composition // rather than agreement: the glyph comes from `Sort::glyph`, so a // fourth spelling cannot appear here without appearing everywhere. assert!(!css.contains("2191") && !css.contains("2193"), "{css}"); assert!(css.contains(&css_escape(Sort::Ascending.glyph())), "{css}"); // Three states, three tones. An idle sortable heading draws its caret // now rather than reserving a hidden box for it, so there is no // visibility to order and no reflow left to guard against; what // separates the states is the colour, and the sorted arms come after // the idle one because the specificity is the same. let idle = css .find(".table-heading[data-sortable]::after") .expect("the idle caret is emitted"); let sorted = css .find(".table-heading[aria-sort=\"ascending\"]::after") .expect("the ascending caret is emitted"); assert!(idle < sorted, "{css}"); assert!( css[idle..sorted].contains("color: var(--content-secondary);"), "{css}" ); assert!(css[sorted..].contains("color: var(--content);"), "{css}"); assert!(!css.contains("visibility: hidden;"), "{css}"); } #[test] fn a_destructive_button_has_somewhere_for_its_tone_to_land() { let css = component_rules(&Emit::default()); for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { assert!( css.contains(&format!(".button[data-tone=\"{}\"]", tone.token())), "{css}" ); } // Colour, not a fill. A red surface is an app's decision about emphasis. assert!(!css.contains(".button[data-tone=\"danger\"] {\n background")); } #[test] fn the_table_model_is_scoped_to_the_table_but_the_caret_is_not() { // goingson's task headings are `.table-heading` inside a CSS grid. // They want the sort caret and the sortable cursor; they do not want // `display: table-cell`, which a grid item blockifies away anyway. // Scoping the one and not the other is what separates them. let css = component_rules(&Emit::default()); assert!( css.contains(".table .table-heading,\n.table .cell {\n display: table-cell;"), "{css}" ); assert!( !css.contains(".table-heading,\n.cell {\n display: table-cell;"), "the table model is still unscoped: {css}" ); let states = state_rules(&Emit::default()); assert!( states.contains(".table-heading[aria-sort"), "the caret got scoped along with the model: {states}" ); } #[test] fn a_figure_value_is_the_thing_itself_and_a_badge_is_quiet() { // Both used to read `Tone::Neutral.token()`, which answered // `content-muted`, so the headline number sat at the colour of its own // caption. Neutral answers `content` now; each site states its own // claim rather than borrowing one from the status axis. let css = component_rules(&Emit::default()); assert!( css.contains(".figure > .figure-value {\n color: var(--content);"), "{css}" ); assert!( css.contains(".figure > .figure-caption {\n color: var(--content-muted);"), "{css}" ); assert!( css.contains(".badge {\n color: var(--content-muted);"), "{css}" ); } #[test] fn a_described_list_is_not_a_bulleted_list() { let css = component_rules(&Emit::default()); assert!(css.contains(".list {\n list-style: none;"), "{css}"); } #[test] fn a_table_lays_itself_out_without_being_told_its_columns() { // The whole point of the CSS table. A described table's columns are // known at render time, so anything the stylesheet has to be told about // them would have to travel with the markup. let css = component_rules(&Emit::default()); assert!(css.contains(".table {\n display: table;"), "{css}"); assert!(css.contains("display: table-row;"), "{css}"); assert!(css.contains("display: table-cell;"), "{css}"); assert!(!css.contains("grid-template-columns"), "{css}"); } #[test] fn a_control_in_a_cell_is_not_painted_as_text() { // The point of makeover-layout 0.14.0's CellPart, and the table-side // twin of `the_three_text_parts_take_their_intents_and_actions_inherits` // above. One `.cell` and one content colour meant a button in a cell // inherited it. let css = table_rules(&Emit::default()); assert!( css.contains(".cell-value {\n color: var(--content);"), "{css}" ); assert!(!css.contains(".cell-actions {\n color:"), "{css}"); assert!(!css.contains(".cell-tokens {\n color:"), "{css}"); assert!(!css.contains(".cell-link {\n color:"), "{css}"); // The colour is on the part that is text, never on the container. On // `.cell` it would cascade into the three parts that are not text, // which is the bug written as one rule. assert!(!css.contains(".cell {\n color:"), "{css}"); } #[test] fn an_unknown_cell_part_renders_plainly_rather_than_failing_to_build() { // `part_class`'s obligation, taken on for the table side too. CellPart // is `#[non_exhaustive]`, so a member added upstream must land as a // bare class rather than as a build that stops. assert_eq!(cell_part_class(CellPart::Value), "cell-value"); assert_eq!(cell_part_class(CellPart::Actions), "cell-actions"); } #[test] fn a_column_drops_by_its_priority_and_never_by_its_position() { let css = component_rules(&Emit::default()); // Optional goes at the narrowest class and secondary goes with it, // which is `kept_at`'s cutoff walk said as two queries. let compact = css .find(&format!("@media {}", SizeClass::Compact.media_condition())) .expect("a compact query"); let medium = css .find(&format!("@media {}", SizeClass::Medium.media_condition())) .expect("a medium query"); assert!(css[compact..].contains(".cell-drops-next"), "{css}"); assert!(!css[medium..].contains(".cell-drops-next"), "{css}"); // Essential columns are never mentioned, because not being mentioned is // already what never dropping means. assert!(!css.contains(".cell-keeps"), "{css}"); // And nothing counts. `nth-child` is the bug the priority vocabulary // exists to end. assert!(!css.contains("nth-child"), "{css}"); } #[test] fn a_track_places_by_custom_property_and_never_by_a_size() { let css = track_rules(&Emit::default()); // Placement arrives from the caller, computed once by Track::fraction. // If either of these becomes a literal, three renderers have started // disagreeing about where 09:30 is. assert!(css.contains("top: var(--track-at"), "{css}"); assert!(css.contains("height: var(--track-for"), "{css}"); // Overlap lanes default so an entry naming neither is full width. assert!(css.contains("--track-lane, 0"), "{css}"); assert!(css.contains("--track-lanes, 1"), "{css}"); // The refusal that matters. A slot height here would be this crate // deciding how tall a quarter of an hour is, which is the thing // makeover-geometry owns and the reason `.track` gets no height at all. assert!(!css.contains("height: var(--track-slot"), "{css}"); for size in ["px", "rem", "em", "vh"] { let bare = css .lines() .filter(|l| !l.contains("var(--")) .any(|l| l.contains(size)); assert!(!bare, "track_rules named a {size} outside a var(): {css}"); } } #[test] fn a_relaxed_part_clamps_and_a_tight_one_says_nothing() { let css = stylesheet(&Emit::default()); assert!(css.contains(".row-relaxed {")); } /// The done condition: a row carrying a level indents in a browser with no /// app-authored CSS. /// /// quasi-webview emits `row-nested` with `--row-depth` on every described /// hierarchy. Without a rule reading it, a described outline is a flat list /// with chevrons in it. #[test] fn a_nested_row_indents_by_its_level_and_an_app_can_say_what_a_level_is_worth() { let css = stylesheet(&Emit::default()); assert!(css.contains(".row-nested {"), "{css}"); assert!( css.contains("padding-inline-start: calc(var(--row-depth, 0) * var(--row-indent, 1.5ch));"), "{css}" ); // The magnitude is a custom property with a fallback, which is // `--awaiting-gap`'s shape: what a level IS stays the description's and // what it is WORTH is this renderer's, overridable by an app. Padding // rather than margin, so the indent is inside the box a selection // shades. assert!( !css.contains("margin-inline-start: calc(var(--row-depth"), "{css}" ); // The branch and its chevron, emitted and unstyled for the same reason. assert!(css.contains(".row-branch {"), "{css}"); assert!(css.contains(".row-disclose {"), "{css}"); for name in ["row-nested", "row-branch", "row-disclose"] { assert!( crate::vocabulary::names(&Emit::default()).contains(name), "{name} is declared" ); } assert!(css.contains("-webkit-line-clamp: 2;")); // The count is `Flow`'s, not this crate's. If the tier ever means three // lines, this fails here rather than in an app. assert!(css.contains(&format!("line-clamp: {};", Flow::Relaxed.lines()))); // Tight gets no rule at all: one line is what a run already does, and a // class per part saying so is a declaration that changes nothing. assert!(!css.contains("row-tight")); assert_eq!(crate::list::flow_class(Flow::Relaxed), Some("row-relaxed")); assert_eq!(crate::list::flow_class(Flow::Tight), None); // Emitted, therefore checkable: an app's dead-vocabulary seal and the // overlap check both read `vocabulary::names`, so a class the renderer // can write and that list does not carry is invisible to both. assert!(crate::vocabulary::names(&Emit::default()).contains("row-relaxed")); } #[test] fn the_two_kinds_of_wait_stop_rendering_identically() { // The state `d43ea1c5` fixes: the emitter had been writing // `data-awaiting` for months and nothing styled either value, so a // measured wait and an unmeasured one drew the same nothing. let css = stylesheet(&Emit::default()); assert!(css.contains("[data-awaiting]::after"), "{css}"); assert!( css.contains("[data-awaiting][aria-busy=\"true\"]::after"), "the mark is drawn only while something is actually waiting" ); assert!( css.contains("[data-awaiting=\"determinate\"][aria-busy=\"true\"]::after"), "the measured half is its own drawing" ); } #[test] fn the_blink_takes_its_cadence_and_never_names_one() { // Three renderers draw this mark. A number written here would be a // second heartbeat for one wait. let css = stylesheet(&Emit::default()); assert!( css.contains("calc(var(--cadence-activity) * 2)"), "a half-period doubled, not a literal" ); assert!(!css.contains("500ms"), "{css}"); } #[test] fn a_bar_nobody_is_counting_is_empty_rather_than_full() { // Rule 1. The share defaults to zero, so a determinate control with no // binder watching bytes draws a trough. A default of 1 would be the // confidently-wrong drawing the rule exists to forbid. let css = stylesheet(&Emit::default()); assert!(css.contains("var(--awaiting-share, 0)"), "{css}"); } #[test] fn motion_off_leaves_the_mark_lit_because_the_keyframes_do_the_dimming() { // `makeover_timing::reduced_motion_css` sets `--cadence-activity: 0ms`, // and a zero-length animation leaves the element in its base style // rather than at its last keyframe. So the base has to be the lit one. // The inverted spelling would blank the mark for a reader who asked for // less motion, which answers a request nobody made. let css = stylesheet(&Emit::default()); let busy = css .split("[data-awaiting][aria-busy=\"true\"]::after {") .nth(1) .expect("the busy rule"); let busy = busy.split('}').next().expect("its body"); assert!( busy.contains("background: var(--action);"), "the base state is lit: {busy}" ); } #[test] fn the_whole_sheet_still_names_every_colour() { // The crate's founding property, asserted over the component layer and // not only the primitives. let css = stylesheet(&Emit::default()); assert!(!css.contains('#')); assert!(!css.contains("rgb")); for line in css.lines() { // Declarations only: a selector or an at-rule can carry a colon of // its own (`:root`, `:hover`, `@media (hover: hover)`) and declares // nothing. Keyed on the trailing semicolon rather than on leading // indentation, which only ever worked as a proxy for nesting depth // and stopped when the sheet gained a cascade layer around it. let trimmed = line.trim(); if !trimmed.ends_with(';') { continue; } let Some((_, value)) = trimmed.split_once(": ") else { continue; }; if value.contains("var(--") { continue; } // Everything left has to be a keyword, a number or a // caller-supplied length, never a colour. // // The length arm is what the comment above always claimed and the // list never covered: `border_width` arrives from `Emit` and lands // bare in the focus ring's offset, where the bevel had only ever // used it inside an `inset` shadow. let opts = Emit::default(); assert!( value.contains("inset") || value.contains(opts.border_width) || value.contains(opts.focus_width) // 0.12.0's two: a sortable header is a control and says so // with the pointer, and the caret is this renderer's own // expression of `aria-sort`. Neither is a colour, which is // what this test is actually about, and neither is a size, // which is the other thing this crate must not name. The // leading space inside the glyph is the same thing the other // two renderers write into theirs, so it is part of the // caret rather than spacing this crate decided on. || value .trim_start_matches('"') .trim_start() .starts_with("\\2") || matches!( value.trim_end_matches(';'), "0" | "1" // The wait's mark and bar, 0.60.0. An empty // `content` is what brings a pseudo-element into // existence with nothing in it, and `step-end` // is an easing: the blink is two states, not a // slide between them. Neither is a colour, and // neither is a magnitude. | "\"\"" // Where the mark sits on the line it joins. // Alignment is structure, the same way `display` // is, and `baseline` is the initial value said out // loud so a host stylesheet cannot leave it // wherever an earlier rule put it. | "baseline" | "inline-block" | "none" | "auto" | "not-allowed" | "pointer" // The caret's reserved box. Visibility is presence, // not magnitude and not colour. | "hidden" | "visible" // The link's two signals. `underline` is a line and // `inherit` defers to whatever the app set, so // neither names a colour or a magnitude. | "underline" | "inherit" // The table frame. `display` is structure and not a // size; `nowrap` is what makes a content column // content. The two widths are the awkward pair and // they are still not sizes: `100%` is "all of // whatever you were given" and `1%` is the CSS // table idiom for "shrink to fit", which is a // behaviour spelled as a number because CSS has no // keyword for it. Neither names a magnitude, which // is the thing this crate leaves to // makeover-geometry. | "table" | "table-row" | "table-cell" | "nowrap" | "100%" | "1%" // The time axis, 0.42.0. `position` is the one // property whose whole job is where a thing sits, // which is exactly what this crate spent its life // refusing to say -- so it is worth being exact // about why these two are not that refusal // breaking. // // Neither names a magnitude. `relative` says the // track is what its entries resolve against, and // `absolute` says an entry is placed rather than // flowed. *Where* each entry lands is // `--track-at` and `--track-for`, custom // properties the caller sets from // `Track::fraction`, and they are skipped by the // `var(--` arm above like every other value this // crate refuses to decide. // // The rule that would break the refusal is a slot // height, and there is none: the track's height is // the app's, so the percentages have something to // resolve against and this crate still never says // how tall a day is. | "relative" | "absolute" // A run's five, 0.49.0. `flex`, `wrap` and // `center` are structure and alignment, the same // reading `table` gets: which way members are laid // out and how they line up, never how much of // anything. // // The two intrinsic keywords are the interesting // pair and they are the opposite of a size. A // magnitude is a number somebody chose; // `min-content` and `max-content` are the browser // being asked what the members themselves come to, // which is the derived minimum the room ruling // requires and the reason no breakpoint appears // anywhere in these rules. `1 1 max-content` is // grow, shrink and that basis, so its two digits // are ratios rather than lengths. | "flex" | "wrap" | "center" | "min-content" | "1 1 max-content" // A run member that absorbs what is left, 0.74.0. // Grow, shrink and a zero basis: the zero is what // makes several fills divide the room equally // rather than dividing the leftovers in proportion // to their contents. Three ratios and no length. | "1 1 0" // A menu run's overflow control, 0.64.0. // `column` and `stretch` are the same reading // `flex` and `center` get one line up: which way // the shed members stack inside the panel and how // they line up across it. Neither is a magnitude. // // `100%` is already above and reused here as the // panel's `inset-block-start`, which is "the whole // of the control it hangs from" rather than a // distance anybody picked. | "column" | "stretch" // A picture's three, 0.36.0. `block` is structure // for the reason `table` is: an inline image sits // on the baseline and carries a descender's worth // of space under it, which is a fact about // replaced elements rather than a size this crate // chose. `cover` and `contain` are `Fit`'s two // named members reaching CSS unchanged, which is // an intent arriving rather than a value being // picked. | "block" | "cover" | "contain" // A relaxed part's three, and the third is the // awkward one. `-webkit-box` and `vertical` are // structure: they say the part is a box of lines // stacked downward, which is the only way CSS lets // anyone ask for a clamp at all. // // `2` is a count of lines, not a length. The // distinction this crate holds is between naming a // magnitude -- a padding, a height, a font size, // all of which belong to makeover-geometry -- and // naming how many of something there are. A line's // height is still the app's, so two lines is // whatever two of the app's lines come to, and // nothing here decides how tall that is. It is also // not a value picked here: it is `Flow::Relaxed`'s // own answer arriving unchanged, the same way // `cover` and `contain` are `Fit`'s. | "-webkit-box" | "vertical" | "2" ), "unrecognised literal value: {line}" ); } } #[test] fn flat_emits_nothing_at_all() { assert_eq!(depth_class(Depth::Flat, &Emit::default()), None); assert!(!depth_rules(&Emit::default()).contains("flat")); } #[test] fn a_prefix_namespaces_every_class() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = depth_rules(&opts); assert!(css.contains(".mo-raised {")); assert!(css.contains(".mo-well {")); assert!(!css.contains(".raised {")); } #[test] fn the_border_width_is_the_callers() { let opts = Emit { border_width: "2px", ..Emit::default() }; assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0")); } #[test] fn edges_agree_with_the_description() { // Not a tautology: it is the guard that a CSS-shaped convenience never // quietly reverses which side is lit. let (tl, br) = Bevel::Raised.edges(); assert_eq!(tl.token(), Edge::Light.token()); assert_eq!(br.token(), Edge::Dark.token()); }