Skip to main content

max / makeover-webview

Move the form and crate-root test modules to sibling files form.rs goes 2688 lines to 1399 with its tests in src/form/tests.rs, and lib.rs 3321 to 2043 with its tests in src/tests.rs. Every #[test] and every fn preserved. No public path moved, so this needs no version bump and no publish.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 23:24 UTC
Signed with PGP, not checked
Commit: 24c5fe03ae117f0237d3c1fd33780e4b313fdb20
Parent: 98a966c
4 files changed, +1000 insertions, -994 deletions
M src/form.rs -497
@@ -1396,1293 +1396,4 @@
1396 1396 }
1397 1397
1398 1398 #[cfg(test)]
1399 - mod tests {
1400 - use super::*;
1401 - use makeover_layout::{Accepted, Curve, Family};
1402 -
1403 - fn field(kind: FieldKind) -> Field<'static> {
1404 - Field::new(kind, "title", "Title")
1405 - }
1406 -
1407 - #[test]
1408 - fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
1409 - // The payload from goingson's own CHRONIC-XSS regression test.
1410 - let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
1411 - let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1412 - // The payload survives as text, which is the point: it is inert
1413 - // because the quote that would have closed the attribute is encoded,
1414 - // not because the words were filtered.
1415 - assert!(!html.contains("\" onfocus"), "{html}");
1416 - assert!(
1417 - html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
1418 - "{html}"
1419 - );
1420 - }
1421 -
1422 - /// The seam quasi's suggestion source needs: a host's own attributes land
1423 - /// on the control, unescaped, and after everything this crate decided.
1424 - #[test]
1425 - fn a_host_can_write_its_own_attributes_onto_the_control() {
1426 - let mut filling = Filling::of(Value::Text("ru"));
1427 - filling.control_attrs = Some(Markup(
1428 - r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
1429 - ));
1430 - let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1431 - assert!(html.contains(r#"role="combobox""#), "{html}");
1432 - assert!(
1433 - html.contains(r#"aria-controls="title-suggestions""#),
1434 - "{html}"
1435 - );
1436 - // After the id, which is what "last" buys: a host can read what this
1437 - // emitter wrote and cannot be overwritten by it.
1438 - let id = html.find(r#"id="title""#).expect("id");
1439 - let role = html.find(r#"role="combobox""#).expect("role");
1440 - assert!(id < role, "{html}");
1441 - }
1442 -
1443 - /// A radio group has no one control element, so there is nowhere honest to
1444 - /// put an attribute meant for the control. Documented on the member.
1445 - #[test]
1446 - fn a_radio_group_drops_control_attributes() {
1447 - let mut f = field(FieldKind::Radio);
1448 - let options = [Choice::new("a", "A")];
1449 - f.options = &options;
1450 - let filling = Filling {
1451 - control_attrs: Some(Markup(r#"data-host="1""#)),
1452 - ..Filling::default()
1453 - };
1454 - let html = field_html(&f, &filling, &Emit::default());
1455 - assert!(!html.contains("data-host"), "{html}");
1456 - }
1457 -
1458 - #[test]
1459 - fn a_label_cannot_open_a_tag() {
1460 - let mut f = field(FieldKind::Text);
1461 - f.label = "<script>alert(1)</script>";
1462 - let html = field_html(&f, &Filling::default(), &Emit::default());
1463 - assert!(!html.contains("<script>"), "{html}");
1464 - assert!(html.contains("&lt;script&gt;"), "{html}");
1465 - }
1466 -
1467 - #[test]
1468 - fn every_escaped_sink_is_covered_by_the_one_escaper() {
1469 - assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
1470 - // The character `textContent` serialization leaves alone, which is why
1471 - // the app needs two escapers and this needs one.
1472 - assert!(escape("\"").contains("&quot;"));
1473 - }
1474 -
1475 - /// The streaming escaper is the one the emitters call and [`escape`] is a
1476 - /// buffer around it, so the two cannot be allowed to drift. It copies in
1477 - /// runs between the encoded characters, which is where a multi-byte
1478 - /// character would break it if the scan were not restricted to ASCII.
1479 - #[test]
1480 - fn the_streaming_escaper_appends_what_the_returning_one_returns() {
1481 - for text in [
1482 - "",
1483 - "plain",
1484 - "&<>\"'",
1485 - "&&&",
1486 - "a & b",
1487 - "trailing&",
1488 - "&leading",
1489 - "é世 & <b>naïve</b> \u{1f600}",
1490 - ] {
1491 - let mut out = String::from("kept: ");
1492 - escape_into(text, &mut out);
1493 - assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
1494 - }
1495 - }
1496 -
1497 - /// Same obligation one layer up: a form is a run of fields appended into one
1498 - /// buffer, and the two ways to get one have to agree byte for byte.
1499 - #[test]
1500 - fn a_streamed_field_is_the_field_the_other_form_returns() {
1501 - let kinds = [
1502 - FieldKind::Text,
1503 - FieldKind::Secret,
1504 - FieldKind::Number,
1505 - FieldKind::Checkbox,
1506 - FieldKind::Radio,
1507 - FieldKind::Select,
1508 - FieldKind::Textarea,
1509 - FieldKind::File,
1510 - FieldKind::Hidden,
1511 - ];
1512 - let choices = [Choice::plain("one"), Choice::plain("two")];
1513 - let opts = Emit {
1514 - class_prefix: "mk-",
1515 - ..Emit::default()
1516 - };
1517 - for kind in kinds {
1518 - let described = Field {
1519 - hint: Some("a hint"),
1520 - error: Some("wrong <here>"),
1521 - placeholder: Some("x\" y"),
1522 - options: &choices,
1523 - required: true,
1524 - max_length: Some(40),
1525 - min: Some("1"),
1526 - max: Some("9"),
1527 - extended: true,
1528 - ..Field::new(kind, "the & name", "The <label>")
1529 - };
1530 - let filling = Filling {
1531 - value: Value::Text("one"),
1532 - trailing: Some(Markup("<i>t</i>")),
1533 - control_attrs: Some(Markup(r#"data-host="1""#)),
1534 - id_prefix: Some("modal"),
1535 - };
1536 - let mut streamed = String::new();
1537 - field_html_into(&described, &filling, &opts, &mut streamed);
1538 - assert_eq!(
1539 - streamed,
1540 - field_html(&described, &filling, &opts),
1541 - "{kind:?}"
1542 - );
1543 -
1544 - // And the bare field, where every optional half is absent.
1545 - let plain = Field::new(kind, "name", "Label");
1546 - let mut streamed = String::new();
1547 - field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
1548 - assert_eq!(
1549 - streamed,
1550 - field_html(&plain, &Filling::default(), &opts),
1551 - "{kind:?}"
1552 - );
1553 - }
1554 - }
1555 -
1556 - #[test]
1557 - fn markup_is_the_only_way_past_the_escaping() {
1558 - let filling = Filling {
1559 - trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
1560 - ..Filling::default()
1561 - };
1562 - let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1563 - assert!(
1564 - html.contains("<div class=\"recurrence-config\"></div>"),
1565 - "{html}"
1566 - );
1567 - }
1568 -
1569 - #[test]
1570 - fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
1571 - let mut f = field(FieldKind::Text);
1572 - f.error = Some("Required");
1573 - let opts = Emit::default();
1574 - let html = field_html(&f, &Filling::default(), &opts);
1575 - assert!(html.contains("aria-invalid=\"true\""), "{html}");
1576 - // The selector the CSS side emits for exactly this state.
1577 - assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
1578 - // And the group is marked too, which a renderer without descendant
1579 - // selectors depends on.
1580 - assert!(html.contains("has-error"), "{html}");
1581 - }
1582 -
1583 - #[test]
1584 - fn a_valid_field_claims_nothing_about_being_invalid() {
1585 - let html = field_html(
1586 - &field(FieldKind::Text),
1587 - &Filling::default(),
1588 - &Emit::default(),
1589 - );
1590 - assert!(!html.contains("aria-invalid"), "{html}");
1591 - assert!(!html.contains("has-error"), "{html}");
1592 - }
1593 -
1594 - #[test]
1595 - fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
1596 - let mut f = field(FieldKind::Text);
1597 - f.hint = Some("Keep it short");
1598 - f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
1599 - f.error = Some("Required");
1600 - let html = field_html(&f, &Filling::default(), &Emit::default());
1601 -
1602 - // All three associated, in the order they are drawn.
1603 - assert!(
1604 - html.contains(r#"aria-describedby="title-hint title-note title-error""#),
1605 - "{html}"
1606 - );
1607 - assert!(
1608 - html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
1609 - "{html}"
1610 - );
1611 - // And in that order in the document, so the reading order matches.
1612 - let hint = html.find("title-hint").unwrap();
1613 - let note = html.rfind("title-note").unwrap();
1614 - let err = html.rfind("title-error").unwrap();
1615 - assert!(hint < note && note < err, "{html}");
1616 - }
1617 -
1618 - #[test]
1619 - fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
1620 - // Neutral is the bare class, matching every other toned component
1621 - // here, and only Warning and Danger interrupt.
1622 - let mut f = field(FieldKind::Text);
1623 - f.note = Some((Tone::Info, "This is what that setting implies"));
1624 - let html = field_html(&f, &Filling::default(), &Emit::default());
1625 - assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
1626 -
1627 - f.note = Some((Tone::Neutral, "An ordinary fact"));
1628 - let html = field_html(&f, &Filling::default(), &Emit::default());
1629 - assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
1630 - assert!(!html.contains("data-tone"), "{html}");
1631 - }
1632 -
1633 - #[test]
1634 - fn a_note_does_not_mark_the_group_invalid() {
1635 - // `Field::invalid` stays `error.is_some()`, and the renderer's
1636 - // `has-error` follows it rather than any message being present.
1637 - let mut f = field(FieldKind::Text);
1638 - f.note = Some((Tone::Danger, "This cannot be undone"));
1639 - let html = field_html(&f, &Filling::default(), &Emit::default());
1640 - assert!(!html.contains("has-error"), "{html}");
1641 - assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
1642 - }
1643 -
1644 - #[test]
1645 - fn the_hint_survives_an_error_arriving() {
1646 - let mut f = field(FieldKind::Text);
1647 - f.hint = Some("Keep it short");
1648 - f.error = Some("Required");
1649 - let html = field_html(&f, &Filling::default(), &Emit::default());
1650 - assert!(
1651 - html.contains("aria-describedby=\"title-hint title-error\""),
1652 - "{html}"
1653 - );
1654 - }
1655 -
1656 - #[test]
1657 - fn a_secret_never_carries_its_value_into_the_markup() {
1658 - let filling = Filling::of(Value::Text("hunter2"));
1659 - let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
1660 - assert!(!html.contains("hunter2"), "{html}");
1661 - assert!(html.contains("type=\"password\""), "{html}");
1662 - }
1663 -
1664 - #[test]
1665 - fn a_hidden_field_is_the_input_and_nothing_else() {
1666 - let filling = Filling::of(Value::Text("42"));
1667 - let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1668 - assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
1669 - }
1670 -
1671 - #[test]
1672 - fn a_checkbox_labels_itself_and_takes_no_separate_label() {
1673 - let html = field_html(
1674 - &field(FieldKind::Checkbox),
1675 - &Filling::of(Value::On(true)),
1676 - &Emit::default(),
1677 - );
1678 - assert!(!html.contains("form-label"), "{html}");
1679 - assert!(html.contains("checked"), "{html}");
1680 - assert!(html.contains("<span>Title</span>"), "{html}");
1681 - }
1682 -
1683 - #[test]
1684 - fn a_select_keeps_a_value_no_option_carries() {
1685 - let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
1686 - let f = Field::select("title", "Title", &options);
1687 - let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1688 - assert!(html.contains("data-unmatched=\"true\""), "{html}");
1689 - // Selected, so the next save round-trips it rather than writing the
1690 - // first option over the top of it.
1691 - assert!(html.contains("<option value=\"10\" selected"), "{html}");
1692 - }
1693 -
1694 - #[test]
1695 - fn a_select_with_no_options_emits_an_empty_select() {
1696 - // The description says a select with no options is sayable, because an
1697 - // app whose option list has not loaded has exactly that. Emitting the
1698 - // empty select reports it on screen rather than in a log.
1699 - let f = Field::select("title", "Title", &[]);
1700 - let html = field_html(&f, &Filling::default(), &Emit::default());
1701 - assert!(html.contains("<select"), "{html}");
1702 - assert!(!html.contains("<option"), "{html}");
1703 - }
1704 -
1705 - #[test]
1706 - fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
1707 - let options = [Choice::new("sp404", "SP-404")];
1708 - let f = Field {
1709 - placeholder: Some("Select device..."),
1710 - ..Field::select("device", "Conform for device", &options)
1711 - };
1712 - let html = field_html(&f, &Filling::default(), &Emit::default());
1713 -
1714 - assert!(
1715 - html.contains("<option value=\"\" disabled selected>Select device...</option>"),
1716 - "{html}"
1717 - );
1718 - // First, so the closed control reads it rather than the first real
1719 - // option.
1720 - assert!(
1721 - html.find("Select device...") < html.find("SP-404"),
1722 - "{html}"
1723 - );
1724 - }
1725 -
1726 - #[test]
1727 - fn an_answered_select_drops_the_ghost_text() {
1728 - // It is an instruction about an empty field, so it has nothing to say
1729 - // once the field is answered, and leaving it in the list is one dead
1730 - // row every time the control is opened afterwards.
1731 - let options = [Choice::new("sp404", "SP-404")];
1732 - let f = Field {
1733 - placeholder: Some("Select device..."),
1734 - ..Field::select("device", "Conform for device", &options)
1735 - };
1736 - let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
1737 - assert!(!html.contains("Select device..."), "{html}");
1738 - }
1739 -
1740 - #[test]
1741 - fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
1742 - // The two paths through `push_options` meet here. An unmatched value is
1743 - // an answer that is wrong and stays visible as itself; only the empty
1744 - // value is unanswered.
1745 - let options = [Choice::plain("1"), Choice::plain("7")];
1746 - let f = Field {
1747 - placeholder: Some("Pick one"),
1748 - ..Field::select("retention", "Keep backups for", &options)
1749 - };
1750 - let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1751 - assert!(html.contains("data-unmatched=\"true\""), "{html}");
1752 - assert!(!html.contains("Pick one"), "{html}");
1753 - }
1754 -
1755 - #[test]
1756 - fn a_range_is_a_range_input_and_carries_its_extent() {
1757 - let f = Field {
1758 - curve: Curve::Linear { step: Some("0.01") },
1759 - ..Field::range("review", "Review above", "0", "1")
1760 - };
1761 - let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
1762 - assert!(html.contains("type=\"range\""), "{html}");
1763 - assert!(html.contains("min=\"0\""), "{html}");
1764 - assert!(html.contains("max=\"1\""), "{html}");
1765 - // Without it the browser steps by 1 and a 0-to-1 question becomes a
1766 - // two-position control.
1767 - assert!(html.contains("step=\"0.01\""), "{html}");
1768 - }
1769 -
1770 - #[test]
1771 - fn a_range_reads_its_granularity_off_the_curve_and_not_off_field_step() {
1772 - // The 0.32.0 narrowing, at the renderer. `Field::step` on a range is a
1773 - // site that has not been moved over, and emitting it would make the
1774 - // control step by a number the curve never agreed to.
1775 - let f = Field {
1776 - step: Some("99"),
1777 - ..Field::range("review", "Review above", "0", "1")
1778 - };
1779 - let html = field_html(&f, &Filling::of(Value::Text("0.5")), &Emit::default());
1780 - assert!(!html.contains("step="), "{html}");
1781 - }
1782 -
1783 - #[test]
1784 - fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
1785 - // Not decoration: the number and what it is measured in are one fact,
1786 - // so the association is what makes this worth emitting at all.
1787 - let f = Field {
1788 - unit: Some("dBFS"),
1789 - ..Field::range("threshold", "Threshold", "-96", "-20")
1790 - };
1791 - let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
1792 - assert!(html.contains(r#"id="threshold-unit""#), "{html}");
1793 - assert!(html.contains(">dBFS</span>"), "{html}");
1794 - assert!(
1795 - html.contains(r#"aria-describedby="threshold-unit""#),
1796 - "{html}"
1797 - );
1798 - // The label is the question's name and keeps no unit in it.
1799 - assert!(html.contains(">Threshold</label>"), "{html}");
1800 - }
1801 -
1802 - #[test]
1803 - fn a_unit_takes_its_place_between_the_hint_and_the_error() {
1804 - let f = Field {
1805 - unit: Some("ms"),
1806 - hint: Some("How long the fade runs."),
1807 - error: Some("Too long."),
1808 - ..Field::new(FieldKind::Number, "fade", "Fade")
1809 - };
1810 - let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
1811 - assert!(
1812 - html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
1813 - "{html}"
1814 - );
1815 - }
1816 -
1817 - #[test]
1818 - fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1819 - // Sayable and ignored, the way `options` is on a kind that offers none.
1820 - // The renderer asks the description which kinds are measurable rather
1821 - // than keeping its own list.
1822 - let f = Field {
1823 - unit: Some("s"),
1824 - ..Field::new(FieldKind::Text, "name", "Name")
1825 - };
1826 - let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
1827 - assert!(!html.contains("name-unit"), "{html}");
1828 - assert!(!html.contains("aria-describedby"), "{html}");
1829 - }
1830 -
1831 - #[test]
1832 - fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
1833 - let f = Field {
1834 - unit: Some("</span><script>"),
1835 - ..Field::new(FieldKind::Number, "n", "N")
1836 - };
1837 - let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
1838 - assert!(!html.contains("<script>"), "{html}");
1839 - assert!(html.contains("&lt;script&gt;"), "{html}");
1840 - }
1841 -
1842 - #[test]
1843 - fn a_constant_ratio_curve_is_answered_with_a_linear_track() {
1844 - // The decided answer, not a shortfall: HTML has no logarithmic range
1845 - // input, so the browser draws the extent linearly. The value it submits
1846 - // is still a value in the field's own units, which is what every
1847 - // handler on this path reads. See the crate header.
1848 - let f = Field {
1849 - curve: Curve::Logarithmic {
1850 - step: Some("0.001"),
1851 - },
1852 - ..Field::range("attack", "Attack", "0.001", "5")
1853 - };
1854 - let html = field_html(&f, &Filling::of(Value::Text("0.005")), &Emit::default());
1855 - assert!(html.contains("type=\"range\""), "{html}");
1856 - assert!(html.contains("min=\"0.001\""), "{html}");
1857 - assert!(html.contains("max=\"5\""), "{html}");
1858 - assert!(html.contains("step=\"0.001\""), "{html}");
1859 - }
1860 -
1861 - #[test]
1862 - fn a_number_with_bounds_is_still_typed_into() {
1863 - // The distinction the kind exists for, at the renderer where getting it
1864 - // wrong is most visible: goingson's `min="1"` duration must not come
1865 - // back as a slider.
1866 - let f = Field {
1867 - min: Some("1"),
1868 - ..Field::new(FieldKind::Number, "minutes", "Minutes")
1869 - };
1870 - let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
1871 - assert!(html.contains("type=\"number\""), "{html}");
1872 - assert!(!html.contains("type=\"range\""), "{html}");
1873 - // And nothing invents a step for it.
1874 - assert!(!html.contains("step="), "{html}");
1875 - }
1876 -
1877 - #[test]
1878 - fn an_unavailable_option_is_disabled_and_says_why() {
1879 - let options = [
1880 - Choice::new("chromatic", "Chromatic"),
1881 - Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1882 - ];
1883 - let f = Field::radio("mode", "Mode", &options);
1884 - let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
1885 -
1886 - assert!(html.contains(" disabled"), "{html}");
1887 - assert!(html.contains("Drop a second sample."), "{html}");
1888 - // The option is still offered: dropping it is what costs the user the
1889 - // knowledge that the mode exists.
1890 - assert!(html.contains("value=\"multi\""), "{html}");
1891 - // And the reason is its own element, not run into the label.
1892 - assert!(html.contains("form-option-reason"), "{html}");
1893 - }
1894 -
1895 - #[test]
Lines truncated
M src/lib.rs -497
@@ -2040,1282 +2040,4 @@
2040 2040 }
2041 2041
2042 2042 #[cfg(test)]
2043 - mod tests {
2044 - use super::*;
2045 - use makeover_layout::Edge;
2046 -
2047 - #[test]
2048 - fn every_fallback_class_is_one_a_checker_knows_about() {
2049 - // The obligation ROW_PART_CLASSES carries, for the same reason: a class
2050 - // this crate can write and the vocabulary list does not carry is
2051 - // invisible to the dead-vocabulary seal and to the overlap check both.
2052 - for fallback in [
2053 - Fallback::Wrap,
2054 - Fallback::Stack,
2055 - Fallback::Shed,
2056 - Fallback::Menu,
2057 - ] {
2058 - assert!(
2059 - RUN_CLASSES.contains(&fallback_class(fallback)),
2060 - "{fallback:?} is missing from RUN_CLASSES"
2061 - );
2062 - }
2063 - let names = crate::vocabulary::names(&Emit::default());
2064 - for name in RUN_CLASSES {
2065 - assert!(names.contains(*name), "{name} is not in the vocabulary");
2066 - }
2067 - }
2068 -
2069 - #[test]
2070 - fn a_run_gives_every_member_a_floor_it_cannot_be_squeezed_below() {
2071 - // The whole of what stops the overlap, and it is not a fallback: it
2072 - // applies to every run whatever the group declared. flexbox's default
2073 - // min-width is auto, which lets an item be compressed below its own
2074 - // content in a nowrap row, and that is how a toolbar is drawn over a
2075 - // tab strip even with nothing out of flow.
2076 - let css = run_rules(&Emit::default());
2077 - assert!(css.contains(".run > * {\n min-width: min-content;\n}"));
2078 - // No number anywhere in it. The minimum is derived by the browser from
2079 - // what the members contain, which is the ruling's own requirement.
2080 - assert!(!css.contains("px"));
2081 - assert!(!css.contains("rem"));
2082 - assert!(!css.contains("@media"));
2083 - }
2084 -
2085 - #[test]
2086 - fn room_is_never_asked_of_the_viewport() {
2087 - // The 913 case: a window in SizeClass::Expanded holding a group out of
2088 - // room. A viewport query answers about the window and would be wrong
2089 - // about the group, which is why the table's @media walk is not the
2090 - // precedent this follows.
2091 - let css = run_rules(&Emit::default());
2092 - for size in [SizeClass::Compact, SizeClass::Medium] {
2093 - assert!(!css.contains(&size.media_condition()));
2094 - }
2095 - }
2096 -
2097 - #[test]
2098 - fn every_fallback_lands_as_a_class_and_an_unknown_one_lands_plainly() {
2099 - let css = run_rules(&Emit::default());
2100 - for fallback in [
2101 - Fallback::Wrap,
2102 - Fallback::Stack,
2103 - Fallback::Shed,
2104 - Fallback::Menu,
2105 - ] {
2106 - let class = fallback_class(fallback);
2107 - assert!(css.contains(&format!(".{class} {{")), "{class} unemitted");
2108 - }
2109 - // Stack is the one that also says what a member does with the line it
2110 - // took, which is what separates it from wrapping.
2111 - assert!(css.contains(".run-stack > * {\n flex: 1 1 max-content;\n}"));
2112 - }
2113 -
2114 - #[test]
2115 - fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
2116 - // Balanced Breakfast's styles.css, verbatim. Adoption has to be a
2117 - // deletion, not a redesign, or nobody will take it.
2118 - let opts = Emit::default();
2119 - assert_eq!(
2120 - bevel_shadow(Bevel::Raised, &opts),
2121 - "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
2122 - );
2123 - assert_eq!(
2124 - bevel_shadow(Bevel::Inset, &opts),
2125 - "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
2126 - );
2127 - }
2128 -
2129 - #[test]
2130 - fn no_colour_ever_reaches_the_output() {
2131 - let css = stylesheet(&Emit::default());
2132 - assert!(!css.contains('#'), "a hex literal escaped into the CSS");
2133 - assert!(
2134 - !css.contains("rgb"),
2135 - "a colour function escaped into the CSS"
2136 - );
2137 - // Every colour is named, never resolved.
2138 - assert!(css.contains("var(--surface-raised)"));
2139 - assert!(css.contains("var(--bevel-light)"));
2140 - }
2141 -
2142 - #[test]
2143 - fn a_well_falls_back_through_css_rather_than_through_rust() {
2144 - assert_eq!(
2145 - fill_var(Fill::Well),
2146 - "var(--surface-well, var(--surface-page))"
2147 - );
2148 - // Nothing else needs one.
2149 - assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
2150 - assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
2151 - }
2152 -
2153 - #[test]
2154 - fn raised_and_well_do_not_collapse_onto_each_other() {
2155 - let css = depth_rules(&Emit::default());
2156 - assert!(css.contains(".raised {"));
2157 - assert!(css.contains(".well {"));
2158 - assert!(css.contains("var(--bevel-raised)"));
2159 - assert!(css.contains("var(--bevel-inset)"));
2160 - }
2161 -
2162 - /// The cast shadow is composed here from the tone `makeover` derives, so
2163 - /// neither crate has to hold the other's numbers.
2164 - ///
2165 - /// It is a `:root` property and deliberately not a depth class. There is no
2166 - /// `Depth::Overlay` in the description layer, and adding one would be a
2167 - /// claim about what a screen means rather than about how it is painted;
2168 - /// until something asks for it, a consumer names the property on the rule
2169 - /// for the menu or the toast it already has.
2170 - #[test]
2171 - fn the_cast_shadow_is_a_root_property_not_a_depth() {
2172 - let css = bevel_properties(&Emit::default());
2173 - assert!(css.contains("--elevation-overlay:"));
2174 - assert!(css.contains("var(--elevation)"));
2175 - assert!(
2176 - !depth_rules(&Emit::default()).contains("elevation"),
2177 - "elevation is not a depth class"
2178 - );
2179 - }
2180 -
2181 - #[test]
2182 - fn the_cascade_carries_the_pressed_state() {
2183 - let css = surface_rules(&Emit::default());
2184 - // The one thing this renderer gets free that the other two resolve by
2185 - // hand, eighteen call sites deep in audiofiles' case. Asserted on a
2186 - // named surface: pressing belongs to the control, not to the depth.
2187 - assert!(css.contains(".card:active {"));
2188 - assert!(css.contains(".button:active {"));
2189 - }
2190 -
2191 - #[test]
2192 - fn the_depth_class_is_a_surface_and_not_a_control() {
2193 - let css = depth_rules(&Emit::default());
2194 - // The static surface the vocabulary was missing. Sixteen goingson
2195 - // elements wore .card and cancelled its hover and press to get this,
2196 - // because a raised object that is not pressable had no other spelling.
2197 - for state in [":hover", ":active", ":focus-visible", ":disabled"] {
2198 - assert!(
2199 - !css.contains(&format!(".raised{state}")),
2200 - "the depth class claimed {state}: {css}"
2201 - );
2202 - }
2203 - assert!(css.contains("var(--bevel-raised)"), "still raised: {css}");
2204 - }
2205 -
2206 - #[test]
2207 - fn pressing_moves_the_fill_and_not_only_the_edge() {
2208 - // The decision-1 guard, and the regression that mattered: emitting the
2209 - // bevel flip alone is what left goingson hand-writing `background:
2210 - // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
2211 - // of the three could be deleted.
2212 - let pressed = interactive_rules("button", Depth::Raised, &Emit::default());
2213 - assert!(pressed.contains(".button:active {"));
2214 - assert!(
2215 - pressed.contains("background: var(--surface-well, var(--surface-page))"),
2216 - "pressed dropped its fill: {pressed}"
2217 - );
2218 - assert!(pressed.contains("box-shadow: var(--bevel-inset)"));
2219 - }
2220 -
2221 - #[test]
2222 - fn pressed_takes_its_fill_from_the_description_not_from_the_app() {
2223 - // goingson presses to --surface-sunken. The description says a pressed
2224 - // raised region reads as a well, and makeover says outright that
2225 - // surface-sunken cannot serve as one, so the app is the thing that
2226 - // moves.
2227 - //
2228 - // Scoped to the pressed rules rather than to the whole sheet: since
2229 - // makeover-layout 0.3.0 an unchosen tab is legitimately
2230 - // --surface-sunken, so the token appearing somewhere in the output no
2231 - // longer means the app's choice leaked in.
2232 - let css = stylesheet(&Emit::default());
2233 - let mut checked = 0;
2234 - for rule in css.split("}\n") {
2235 - if !rule.contains(":active") {
2236 - continue;
2237 - }
2238 - checked += 1;
2239 - assert!(
2240 - !rule.contains("surface-sunken"),
2241 - "a pressed rule took the app's fill: {rule}"
2242 - );
2243 - }
2244 - assert!(checked > 0, "no pressed rules found to check");
2245 - assert_eq!(
2246 - Depth::Raised.pressed().fill(),
2247 - Some(Fill::Well),
2248 - "the description changed under us"
2249 - );
2250 - }
2251 -
2252 - #[test]
2253 - fn the_whole_stylesheet_is_emitted_in_the_family_layer() {
2254 - // The point of 0.11.0. Unlayered normal declarations outrank every
2255 - // named layer, so an app declaring `@layer base, components` loses
2256 - // every rule it owns to this file until this file is layered too.
2257 - let css = stylesheet(&Emit::default());
2258 - assert!(css.contains(&format!("@layer {CSS_LAYER} {{")));
2259 -
2260 - // Exactly one layer block, and nothing outside it but the banner.
2261 - assert_eq!(css.matches("@layer").count(), 2, "banner names it once");
2262 - let opened = css.find("@layer makeover {").expect("layer opens");
2263 - for (i, line) in css.lines().enumerate() {
2264 - let before_layer = css.lines().take(i).map(str::len).sum::<usize>() < opened;
2265 - if before_layer || line.is_empty() {
2266 - continue;
2267 - }
2268 - assert!(
2269 - line.starts_with(" ") || line == "}" || line.starts_with(" "),
2270 - "line outside the layer: {line:?}"
2271 - );
2272 - }
2273 - }
2274 -
2275 - #[test]
2276 - fn the_generated_sheet_carries_no_trailing_whitespace() {
2277 - // A checked-in generated file that a formatter wants to rewrite is a
2278 - // diff every time somebody saves it.
2279 - let css = stylesheet(&Emit::default());
2280 - for (i, line) in css.lines().enumerate() {
2281 - assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}");
2282 - }
2283 - }
2284 -
2285 - #[test]
2286 - fn the_banner_tells_an_app_how_to_order_the_layer() {
2287 - // Without a declared order the layer's position depends on which
2288 - // generated file the browser sees first, which is not a contract.
2289 - let css = stylesheet(&Emit::default());
2290 - assert!(css.contains("@layer makeover, base, components, responsive;"));
2291 - // And the banner is outside the layer, not a rule inside it.
2292 - assert!(css.starts_with("/* Generated by makeover-webview"));
2293 - }
2294 -
2295 - #[test]
2296 - fn the_banner_names_the_emitter_so_a_stale_pin_is_visible_on_sight() {
2297 - // A consumer whose lockfile pins an old version gets a well-formed
2298 - // sheet with components missing and no error. balanced_breakfast ran
2299 - // on 657 bytes from a 0.1.0 emitter while its manifest asked for
2300 - // 0.5.1, and the only way it surfaced was diffing two apps' generated
2301 - // files. The version and the count are what the file says instead.
2302 - let css = stylesheet(&Emit::default());
2303 - let banner = css.lines().next().unwrap();
2304 - assert!(
2305 - banner.contains(VERSION),
2306 - "{banner} does not name the emitter"
2307 - );
2308 - let classes = vocabulary::classes_in_css(&css).len();
2309 - assert!(classes > 0);
2310 - assert!(
2311 - banner.contains(&format!("{classes} classes")),
2312 - "{banner} does not carry the class count"
2313 - );
2314 - }
2315 -
2316 - #[test]
2317 - fn a_primitive_owns_every_state_it_implies() {
2318 - // The whole point of 0.10.0. Anything emitting a hover rule owes the
2319 - // other three, or the consuming app supplies them by out-specifying a
2320 - // rule it does not own: 19 such rules in goingson, 21 in the MNW
2321 - // server, and three focus rings that do not match.
2322 - let css = stylesheet(&Emit::default());
2323 - for selector in ["button", "card", "chip", "tab", "segment", "toggle"] {
2324 - assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}");
2325 - assert!(
2326 - css.contains(&format!(".{selector}:active {{")),
2327 - "{selector}"
2328 - );
2329 - assert!(
2330 - css.contains(&format!(".{selector}:focus-visible {{")),
2331 - "{selector} has no focus ring"
2332 - );
2333 - assert!(
2334 - css.contains(&format!(".{selector}:disabled,")),
2335 - "{selector} has no disabled state"
2336 - );
2337 - }
2338 - }
2339 -
2340 - #[test]
2341 - fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() {
2342 - // A text field does not light up under the pointer, so it gets the two
2343 - // states it has and not the two it does not.
2344 - let css = stylesheet(&Emit::default());
2345 - assert!(css.contains(".field:focus-visible {"));
2346 - assert!(css.contains(".field:disabled,"));
2347 - assert!(!css.contains(".field:hover {"));
2348 - assert!(!css.contains(".field:active {"));
2349 - }
2350 -
2351 - #[test]
2352 - fn disabled_is_emitted_after_hover_so_source_order_settles_it() {
2353 - // Every one of these selectors is specificity (0,2,0), so nothing but
2354 - // order decides which wins. A disabled button taking the hover fill is
2355 - // the exact bug goingson's `.button:disabled:hover` was written to fix,
2356 - // and the reason it had to reach (0,3,0) to do it.
2357 - let css = interactive_rules("button", Depth::Raised, &Emit::default());
2358 - let hover = css.find(":hover").expect("hover");
2359 - let active = css.find(":active").expect("active");
2360 - let focus = css.find(":focus-visible").expect("focus");
2361 - let disabled = css.find(":disabled").expect("disabled");
2362 - assert!(hover < active && active < focus && focus < disabled);
2363 -
2364 - // And it restores the surface, or the hover fill survives underneath.
2365 - let tail = &css[disabled..];
2366 - assert!(tail.contains("background: var(--surface-raised)"));
2367 - }
2368 -
2369 - #[test]
2370 - fn a_flat_control_takes_its_hover_fill_back_when_it_stops_answering() {
2371 - // The same contest one depth over, and the half `depth_declarations`
2372 - // could not state. Flat declares neither axis, so before 0.68.0 the
2373 - // disabled rule won on source order with nothing to say and the hover
2374 - // surface stayed under a control that had stopped answering. Reaches
2375 - // both facet arms and a suggestion entry.
2376 - for depth in [Depth::Flat, Depth::Sunken, Depth::Overlay] {
2377 - let css = disabled_rule("x", depth);
2378 - assert!(css.contains("box-shadow"), "{depth:?}: {css}");
2379 - }
2380 - let flat = disabled_rule("x", Depth::Flat);
2381 - assert!(flat.contains("background: none;"), "{flat}");
2382 - assert!(flat.contains("box-shadow: none;"), "{flat}");
2383 -
2384 - // Every rule the caller emits states both axes now, so whichever wins
2385 - // the source-order contest leaves nothing of the one below it.
2386 - let css = interactive_rules("x", Depth::Flat, &Emit::default());
2387 - let disabled = css.find(":disabled").expect("disabled");
2388 - assert!(css[disabled..].contains("background: none;"), "{css}");
2389 - }
2390 -
2391 - #[test]
2392 - fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
2393 - // `:disabled` matches form elements only, and a chip is a div. Keying
2394 - // on the ARIA attribute too is the pattern the invalid field already
2395 - // set: one fact, read by the styling and the accessibility tree alike.
2396 - let css = disabled_rule("chip", Depth::Raised);
2397 - assert!(css.contains(".chip:disabled,"));
2398 - assert!(css.contains(".chip[aria-disabled=\"true\"]"));
2399 - assert!(css.contains("cursor: not-allowed"));
2400 - }
2401 -
2402 - #[test]
2403 - fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() {
2404 - // `outline` has its own property, so unlike the invalid ring there is
2405 - // no bevel to restate beside it and nothing to keep in agreement.
2406 - let opts = Emit::default();
2407 - let css = focus_rule("button", Depth::Raised, &opts);
2408 - assert!(css.contains("outline: 2px solid var(--focus-ring)"));
2409 - assert!(!css.contains("box-shadow"), "the ring restated the bevel");
2410 - }
2411 -
2412 - #[test]
2413 - fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() {
2414 - // One ring, placed by depth. The offset comes off `Depth::bevel` and
2415 - // not off a per-component choice, which is what gave three apps three
2416 - // different rings.
2417 - let opts = Emit::default();
2418 - assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)"));
2419 - assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px"));
2420 - // Nothing to sit inside of, so it sits outside.
2421 - assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px"));
2422 -
2423 - // And the ring is not the bevel. Reusing border_width emitted a 1px
2424 - // ring that every consumer had already overridden.
2425 - assert_ne!(opts.focus_width, opts.border_width);
2426 - }
2427 -
2428 - #[test]
2429 - fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() {
2430 - // goingson's section 60 exists only to take back the hover state this
2431 - // crate handed it. Gating at the source is what deletes that section
2432 - // in all three apps rather than having each fight for it.
2433 - let css = stylesheet(&Emit::default());
2434 - let condition = format!("@media {}", Density::Pointer.media_condition());
2435 - assert!(css.contains(&condition));
2436 -
2437 - // What is gated is every hover state the surfaces carry. The row's
2438 - // actions used to be the other half of this test and are not gated any
2439 - // more, because they are not hidden any more: a rule that reveals
2440 - // nothing needs no capability answer.
2441 - let gated: Vec<&str> = css.lines().filter(|line| line.contains(":hover")).collect();
2442 - assert!(!gated.is_empty(), "{css}");
2443 - for line in gated {
2444 - let indent = line.len() - line.trim_start().len();
2445 - assert!(indent > 4, "an ungated hover rule: {line}");
2446 - }
2447 - assert!(!css.contains(".row:hover"), "{css}");
2448 - }
2449 -
2450 - #[test]
2451 - fn the_capability_answer_is_asked_for_and_not_assumed() {
2452 - // Both halves come from the crates that own them. If `makeover-touch`
2453 - // ever says a fingertip has hover, this stops gating on its own.
2454 - assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact));
2455 - assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact));
2456 - assert_eq!(hover_condition(), Some(Density::Pointer.media_condition()));
2457 -
2458 - // And the size class passed to that call is not a claim about width.
2459 - assert!(Affordance::Hover.reads_density());
2460 - for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] {
2461 - assert!(!Affordance::Hover.available(Density::Touch, size));
2462 - }
2463 - }
2464 -
2465 - #[test]
2466 - fn hover_resolves_against_the_token_makeover_already_derives() {
2467 - let css = interactive_rules("card", Depth::Raised, &Emit::default());
2468 - assert!(css.contains(".card:hover {"));
2469 - assert!(css.contains("background: var(--hover-surface)"));
2470 - // Not the app's choice, which was --surface-overlay.
2471 - assert!(!css.contains("surface-overlay"));
2472 - }
2473 -
2474 - #[test]
2475 - fn a_badge_gets_no_edge_and_no_fill() {
2476 - // Decision 2, and the one visible redesign in phase A. Token::Badge is
2477 - // Flat: an edge on a label says it can be pressed.
2478 - let css = token_rules(&Emit::default());
2479 - let badge = css
2480 - .lines()
2481 - .skip_while(|l| !l.starts_with(".badge {"))
2482 - .take_while(|l| !l.starts_with('}'))
2483 - .collect::<Vec<_>>()
2484 - .join("\n");
2485 - assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}");
2486 - assert!(!badge.contains("background"), "badge kept a fill: {badge}");
2487 - assert_eq!(Token::Badge.depth(false), Depth::Flat);
2488 - assert_eq!(Token::Badge.depth(true), Depth::Flat);
2489 - }
2490 -
2491 - #[test]
2492 - fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() {
2493 - let css = token_rules(&Emit::default());
2494 - // Neutral is the absence of a status, not a status named "none".
2495 - assert!(css.contains(".badge {\n color: var(--content-muted);"));
2496 - assert!(!css.contains("data-tone=\"content-muted\""));
2497 - for tone in ["info", "success", "warning", "danger"] {
2498 - assert!(
2499 - css.contains(&format!(".badge[data-tone=\"{tone}\"]")),
2500 - "missing tone {tone}"
2501 - );
2502 - assert!(css.contains(&format!("color: var(--{tone})")));
2503 - }
2504 - }
2505 -
2506 - #[test]
2507 - fn a_chip_is_raised_and_latches_into_a_well() {
2508 - let css = token_rules(&Emit::default());
2509 - assert!(css.contains(".chip {"));
2510 - assert!(css.contains(".chip.latched {"));
2511 - assert!(css.contains(".chip:active {"));
2512 - // The whole difference from a badge: it answers a click.
2513 - assert!(Token::Chip { removable: false }.interactive());
2514 - assert!(!Token::Badge.interactive());
2515 - }
2516 -
2517 - #[test]
2518 - fn only_a_tab_comes_forward_when_chosen() {
2519 - // The folder semantic. Collapsing the three selectors would lose it.
2520 - let css = selector_rules(&Emit::default());
2521 - assert!(css.contains(".tab.chosen {"));
2522 - assert!(css.contains(".segment.chosen {"));
2523 - assert!(css.contains(".toggle.chosen {"));
2524 - assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
2525 - assert_eq!(Selector::Segmented.chosen(), Depth::Well);
2526 - assert_eq!(Selector::Toggle.chosen(), Depth::Well);
2527 -
2528 - let tab = css
2529 - .lines()
2530 - .skip_while(|l| !l.starts_with(".tab.chosen {"))
2531 - .take_while(|l| !l.starts_with('}'))
2532 - .collect::<Vec<_>>()
2533 - .join("\n");
2534 - assert!(
2535 - tab.contains("var(--bevel-raised)"),
2536 - "tab was held in: {tab}"
2537 - );
2538 - }
2539 -
Lines truncated
@@ -1,0 +1,1283 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use makeover_layout::{Accepted, Curve, Family};
5 +
6 + fn field(kind: FieldKind) -> Field<'static> {
7 + Field::new(kind, "title", "Title")
8 + }
9 +
10 + #[test]
11 + fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
12 + // The payload from goingson's own CHRONIC-XSS regression test.
13 + let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
14 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
15 + // The payload survives as text, which is the point: it is inert
16 + // because the quote that would have closed the attribute is encoded,
17 + // not because the words were filtered.
18 + assert!(!html.contains("\" onfocus"), "{html}");
19 + assert!(
20 + html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
21 + "{html}"
22 + );
23 + }
24 +
25 + /// The seam quasi's suggestion source needs: a host's own attributes land
26 + /// on the control, unescaped, and after everything this crate decided.
27 + #[test]
28 + fn a_host_can_write_its_own_attributes_onto_the_control() {
29 + let mut filling = Filling::of(Value::Text("ru"));
30 + filling.control_attrs = Some(Markup(
31 + r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
32 + ));
33 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
34 + assert!(html.contains(r#"role="combobox""#), "{html}");
35 + assert!(
36 + html.contains(r#"aria-controls="title-suggestions""#),
37 + "{html}"
38 + );
39 + // After the id, which is what "last" buys: a host can read what this
40 + // emitter wrote and cannot be overwritten by it.
41 + let id = html.find(r#"id="title""#).expect("id");
42 + let role = html.find(r#"role="combobox""#).expect("role");
43 + assert!(id < role, "{html}");
44 + }
45 +
46 + /// A radio group has no one control element, so there is nowhere honest to
47 + /// put an attribute meant for the control. Documented on the member.
48 + #[test]
49 + fn a_radio_group_drops_control_attributes() {
50 + let mut f = field(FieldKind::Radio);
51 + let options = [Choice::new("a", "A")];
52 + f.options = &options;
53 + let filling = Filling {
54 + control_attrs: Some(Markup(r#"data-host="1""#)),
55 + ..Filling::default()
56 + };
57 + let html = field_html(&f, &filling, &Emit::default());
58 + assert!(!html.contains("data-host"), "{html}");
59 + }
60 +
61 + #[test]
62 + fn a_label_cannot_open_a_tag() {
63 + let mut f = field(FieldKind::Text);
64 + f.label = "<script>alert(1)</script>";
65 + let html = field_html(&f, &Filling::default(), &Emit::default());
66 + assert!(!html.contains("<script>"), "{html}");
67 + assert!(html.contains("&lt;script&gt;"), "{html}");
68 + }
69 +
70 + #[test]
71 + fn every_escaped_sink_is_covered_by_the_one_escaper() {
72 + assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
73 + // The character `textContent` serialization leaves alone, which is why
74 + // the app needs two escapers and this needs one.
75 + assert!(escape("\"").contains("&quot;"));
76 + }
77 +
78 + /// The streaming escaper is the one the emitters call and [`escape`] is a
79 + /// buffer around it, so the two cannot be allowed to drift. It copies in
80 + /// runs between the encoded characters, which is where a multi-byte
81 + /// character would break it if the scan were not restricted to ASCII.
82 + #[test]
83 + fn the_streaming_escaper_appends_what_the_returning_one_returns() {
84 + for text in [
85 + "",
86 + "plain",
87 + "&<>\"'",
88 + "&&&",
89 + "a & b",
90 + "trailing&",
91 + "&leading",
92 + "é世 & <b>naïve</b> \u{1f600}",
93 + ] {
94 + let mut out = String::from("kept: ");
95 + escape_into(text, &mut out);
96 + assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
97 + }
98 + }
99 +
100 + /// Same obligation one layer up: a form is a run of fields appended into one
101 + /// buffer, and the two ways to get one have to agree byte for byte.
102 + #[test]
103 + fn a_streamed_field_is_the_field_the_other_form_returns() {
104 + let kinds = [
105 + FieldKind::Text,
106 + FieldKind::Secret,
107 + FieldKind::Number,
108 + FieldKind::Checkbox,
109 + FieldKind::Radio,
110 + FieldKind::Select,
111 + FieldKind::Textarea,
112 + FieldKind::File,
113 + FieldKind::Hidden,
114 + ];
115 + let choices = [Choice::plain("one"), Choice::plain("two")];
116 + let opts = Emit {
117 + class_prefix: "mk-",
118 + ..Emit::default()
119 + };
120 + for kind in kinds {
121 + let described = Field {
122 + hint: Some("a hint"),
123 + error: Some("wrong <here>"),
124 + placeholder: Some("x\" y"),
125 + options: &choices,
126 + required: true,
127 + max_length: Some(40),
128 + min: Some("1"),
129 + max: Some("9"),
130 + extended: true,
131 + ..Field::new(kind, "the & name", "The <label>")
132 + };
133 + let filling = Filling {
134 + value: Value::Text("one"),
135 + trailing: Some(Markup("<i>t</i>")),
136 + control_attrs: Some(Markup(r#"data-host="1""#)),
137 + id_prefix: Some("modal"),
138 + };
139 + let mut streamed = String::new();
140 + field_html_into(&described, &filling, &opts, &mut streamed);
141 + assert_eq!(
142 + streamed,
143 + field_html(&described, &filling, &opts),
144 + "{kind:?}"
145 + );
146 +
147 + // And the bare field, where every optional half is absent.
148 + let plain = Field::new(kind, "name", "Label");
149 + let mut streamed = String::new();
150 + field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
151 + assert_eq!(
152 + streamed,
153 + field_html(&plain, &Filling::default(), &opts),
154 + "{kind:?}"
155 + );
156 + }
157 + }
158 +
159 + #[test]
160 + fn markup_is_the_only_way_past_the_escaping() {
161 + let filling = Filling {
162 + trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
163 + ..Filling::default()
164 + };
165 + let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
166 + assert!(
167 + html.contains("<div class=\"recurrence-config\"></div>"),
168 + "{html}"
169 + );
170 + }
171 +
172 + #[test]
173 + fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
174 + let mut f = field(FieldKind::Text);
175 + f.error = Some("Required");
176 + let opts = Emit::default();
177 + let html = field_html(&f, &Filling::default(), &opts);
178 + assert!(html.contains("aria-invalid=\"true\""), "{html}");
179 + // The selector the CSS side emits for exactly this state.
180 + assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
181 + // And the group is marked too, which a renderer without descendant
182 + // selectors depends on.
183 + assert!(html.contains("has-error"), "{html}");
184 + }
185 +
186 + #[test]
187 + fn a_valid_field_claims_nothing_about_being_invalid() {
188 + let html = field_html(
189 + &field(FieldKind::Text),
190 + &Filling::default(),
191 + &Emit::default(),
192 + );
193 + assert!(!html.contains("aria-invalid"), "{html}");
194 + assert!(!html.contains("has-error"), "{html}");
195 + }
196 +
197 + #[test]
198 + fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
199 + let mut f = field(FieldKind::Text);
200 + f.hint = Some("Keep it short");
201 + f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
202 + f.error = Some("Required");
203 + let html = field_html(&f, &Filling::default(), &Emit::default());
204 +
205 + // All three associated, in the order they are drawn.
206 + assert!(
207 + html.contains(r#"aria-describedby="title-hint title-note title-error""#),
208 + "{html}"
209 + );
210 + assert!(
211 + html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
212 + "{html}"
213 + );
214 + // And in that order in the document, so the reading order matches.
215 + let hint = html.find("title-hint").unwrap();
216 + let note = html.rfind("title-note").unwrap();
217 + let err = html.rfind("title-error").unwrap();
218 + assert!(hint < note && note < err, "{html}");
219 + }
220 +
221 + #[test]
222 + fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
223 + // Neutral is the bare class, matching every other toned component
224 + // here, and only Warning and Danger interrupt.
225 + let mut f = field(FieldKind::Text);
226 + f.note = Some((Tone::Info, "This is what that setting implies"));
227 + let html = field_html(&f, &Filling::default(), &Emit::default());
228 + assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
229 +
230 + f.note = Some((Tone::Neutral, "An ordinary fact"));
231 + let html = field_html(&f, &Filling::default(), &Emit::default());
232 + assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
233 + assert!(!html.contains("data-tone"), "{html}");
234 + }
235 +
236 + #[test]
237 + fn a_note_does_not_mark_the_group_invalid() {
238 + // `Field::invalid` stays `error.is_some()`, and the renderer's
239 + // `has-error` follows it rather than any message being present.
240 + let mut f = field(FieldKind::Text);
241 + f.note = Some((Tone::Danger, "This cannot be undone"));
242 + let html = field_html(&f, &Filling::default(), &Emit::default());
243 + assert!(!html.contains("has-error"), "{html}");
244 + assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
245 + }
246 +
247 + #[test]
248 + fn the_hint_survives_an_error_arriving() {
249 + let mut f = field(FieldKind::Text);
250 + f.hint = Some("Keep it short");
251 + f.error = Some("Required");
252 + let html = field_html(&f, &Filling::default(), &Emit::default());
253 + assert!(
254 + html.contains("aria-describedby=\"title-hint title-error\""),
255 + "{html}"
256 + );
257 + }
258 +
259 + #[test]
260 + fn a_secret_never_carries_its_value_into_the_markup() {
261 + let filling = Filling::of(Value::Text("hunter2"));
262 + let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
263 + assert!(!html.contains("hunter2"), "{html}");
264 + assert!(html.contains("type=\"password\""), "{html}");
265 + }
266 +
267 + #[test]
268 + fn a_hidden_field_is_the_input_and_nothing_else() {
269 + let filling = Filling::of(Value::Text("42"));
270 + let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
271 + assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
272 + }
273 +
274 + #[test]
275 + fn a_checkbox_labels_itself_and_takes_no_separate_label() {
276 + let html = field_html(
277 + &field(FieldKind::Checkbox),
278 + &Filling::of(Value::On(true)),
279 + &Emit::default(),
280 + );
281 + assert!(!html.contains("form-label"), "{html}");
282 + assert!(html.contains("checked"), "{html}");
283 + assert!(html.contains("<span>Title</span>"), "{html}");
284 + }
285 +
286 + #[test]
287 + fn a_select_keeps_a_value_no_option_carries() {
288 + let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
289 + let f = Field::select("title", "Title", &options);
290 + let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
291 + assert!(html.contains("data-unmatched=\"true\""), "{html}");
292 + // Selected, so the next save round-trips it rather than writing the
293 + // first option over the top of it.
294 + assert!(html.contains("<option value=\"10\" selected"), "{html}");
295 + }
296 +
297 + #[test]
298 + fn a_select_with_no_options_emits_an_empty_select() {
299 + // The description says a select with no options is sayable, because an
300 + // app whose option list has not loaded has exactly that. Emitting the
301 + // empty select reports it on screen rather than in a log.
302 + let f = Field::select("title", "Title", &[]);
303 + let html = field_html(&f, &Filling::default(), &Emit::default());
304 + assert!(html.contains("<select"), "{html}");
305 + assert!(!html.contains("<option"), "{html}");
306 + }
307 +
308 + #[test]
309 + fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
310 + let options = [Choice::new("sp404", "SP-404")];
311 + let f = Field {
312 + placeholder: Some("Select device..."),
313 + ..Field::select("device", "Conform for device", &options)
314 + };
315 + let html = field_html(&f, &Filling::default(), &Emit::default());
316 +
317 + assert!(
318 + html.contains("<option value=\"\" disabled selected>Select device...</option>"),
319 + "{html}"
320 + );
321 + // First, so the closed control reads it rather than the first real
322 + // option.
323 + assert!(
324 + html.find("Select device...") < html.find("SP-404"),
325 + "{html}"
326 + );
327 + }
328 +
329 + #[test]
330 + fn an_answered_select_drops_the_ghost_text() {
331 + // It is an instruction about an empty field, so it has nothing to say
332 + // once the field is answered, and leaving it in the list is one dead
333 + // row every time the control is opened afterwards.
334 + let options = [Choice::new("sp404", "SP-404")];
335 + let f = Field {
336 + placeholder: Some("Select device..."),
337 + ..Field::select("device", "Conform for device", &options)
338 + };
339 + let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
340 + assert!(!html.contains("Select device..."), "{html}");
341 + }
342 +
343 + #[test]
344 + fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
345 + // The two paths through `push_options` meet here. An unmatched value is
346 + // an answer that is wrong and stays visible as itself; only the empty
347 + // value is unanswered.
348 + let options = [Choice::plain("1"), Choice::plain("7")];
349 + let f = Field {
350 + placeholder: Some("Pick one"),
351 + ..Field::select("retention", "Keep backups for", &options)
352 + };
353 + let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
354 + assert!(html.contains("data-unmatched=\"true\""), "{html}");
355 + assert!(!html.contains("Pick one"), "{html}");
356 + }
357 +
358 + #[test]
359 + fn a_range_is_a_range_input_and_carries_its_extent() {
360 + let f = Field {
361 + curve: Curve::Linear { step: Some("0.01") },
362 + ..Field::range("review", "Review above", "0", "1")
363 + };
364 + let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
365 + assert!(html.contains("type=\"range\""), "{html}");
366 + assert!(html.contains("min=\"0\""), "{html}");
367 + assert!(html.contains("max=\"1\""), "{html}");
368 + // Without it the browser steps by 1 and a 0-to-1 question becomes a
369 + // two-position control.
370 + assert!(html.contains("step=\"0.01\""), "{html}");
371 + }
372 +
373 + #[test]
374 + fn a_range_reads_its_granularity_off_the_curve_and_not_off_field_step() {
375 + // The 0.32.0 narrowing, at the renderer. `Field::step` on a range is a
376 + // site that has not been moved over, and emitting it would make the
377 + // control step by a number the curve never agreed to.
378 + let f = Field {
379 + step: Some("99"),
380 + ..Field::range("review", "Review above", "0", "1")
381 + };
382 + let html = field_html(&f, &Filling::of(Value::Text("0.5")), &Emit::default());
383 + assert!(!html.contains("step="), "{html}");
384 + }
385 +
386 + #[test]
387 + fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
388 + // Not decoration: the number and what it is measured in are one fact,
389 + // so the association is what makes this worth emitting at all.
390 + let f = Field {
391 + unit: Some("dBFS"),
392 + ..Field::range("threshold", "Threshold", "-96", "-20")
393 + };
394 + let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
395 + assert!(html.contains(r#"id="threshold-unit""#), "{html}");
396 + assert!(html.contains(">dBFS</span>"), "{html}");
397 + assert!(
398 + html.contains(r#"aria-describedby="threshold-unit""#),
399 + "{html}"
400 + );
401 + // The label is the question's name and keeps no unit in it.
402 + assert!(html.contains(">Threshold</label>"), "{html}");
403 + }
404 +
405 + #[test]
406 + fn a_unit_takes_its_place_between_the_hint_and_the_error() {
407 + let f = Field {
408 + unit: Some("ms"),
409 + hint: Some("How long the fade runs."),
410 + error: Some("Too long."),
411 + ..Field::new(FieldKind::Number, "fade", "Fade")
412 + };
413 + let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
414 + assert!(
415 + html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
416 + "{html}"
417 + );
418 + }
419 +
420 + #[test]
421 + fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
422 + // Sayable and ignored, the way `options` is on a kind that offers none.
423 + // The renderer asks the description which kinds are measurable rather
424 + // than keeping its own list.
425 + let f = Field {
426 + unit: Some("s"),
427 + ..Field::new(FieldKind::Text, "name", "Name")
428 + };
429 + let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
430 + assert!(!html.contains("name-unit"), "{html}");
431 + assert!(!html.contains("aria-describedby"), "{html}");
432 + }
433 +
434 + #[test]
435 + fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
436 + let f = Field {
437 + unit: Some("</span><script>"),
438 + ..Field::new(FieldKind::Number, "n", "N")
439 + };
440 + let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
441 + assert!(!html.contains("<script>"), "{html}");
442 + assert!(html.contains("&lt;script&gt;"), "{html}");
443 + }
444 +
445 + #[test]
446 + fn a_constant_ratio_curve_is_answered_with_a_linear_track() {
447 + // The decided answer, not a shortfall: HTML has no logarithmic range
448 + // input, so the browser draws the extent linearly. The value it submits
449 + // is still a value in the field's own units, which is what every
450 + // handler on this path reads. See the crate header.
451 + let f = Field {
452 + curve: Curve::Logarithmic {
453 + step: Some("0.001"),
454 + },
455 + ..Field::range("attack", "Attack", "0.001", "5")
456 + };
457 + let html = field_html(&f, &Filling::of(Value::Text("0.005")), &Emit::default());
458 + assert!(html.contains("type=\"range\""), "{html}");
459 + assert!(html.contains("min=\"0.001\""), "{html}");
460 + assert!(html.contains("max=\"5\""), "{html}");
461 + assert!(html.contains("step=\"0.001\""), "{html}");
462 + }
463 +
464 + #[test]
465 + fn a_number_with_bounds_is_still_typed_into() {
466 + // The distinction the kind exists for, at the renderer where getting it
467 + // wrong is most visible: goingson's `min="1"` duration must not come
468 + // back as a slider.
469 + let f = Field {
470 + min: Some("1"),
471 + ..Field::new(FieldKind::Number, "minutes", "Minutes")
472 + };
473 + let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
474 + assert!(html.contains("type=\"number\""), "{html}");
475 + assert!(!html.contains("type=\"range\""), "{html}");
476 + // And nothing invents a step for it.
477 + assert!(!html.contains("step="), "{html}");
478 + }
479 +
480 + #[test]
481 + fn an_unavailable_option_is_disabled_and_says_why() {
482 + let options = [
483 + Choice::new("chromatic", "Chromatic"),
484 + Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
485 + ];
486 + let f = Field::radio("mode", "Mode", &options);
487 + let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
488 +
489 + assert!(html.contains(" disabled"), "{html}");
490 + assert!(html.contains("Drop a second sample."), "{html}");
491 + // The option is still offered: dropping it is what costs the user the
492 + // knowledge that the mode exists.
493 + assert!(html.contains("value=\"multi\""), "{html}");
494 + // And the reason is its own element, not run into the label.
495 + assert!(html.contains("form-option-reason"), "{html}");
496 + }
497 +
498 + #[test]
499 + fn an_unavailable_select_option_carries_its_reason_in_its_text() {
500 + // A `<select>` gives an option no room for a second element, so the
Lines truncated
A src/tests.rs +500
@@ -1,0 +1,1275 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use makeover_layout::Edge;
5 +
6 + #[test]
7 + fn every_fallback_class_is_one_a_checker_knows_about() {
8 + // The obligation ROW_PART_CLASSES carries, for the same reason: a class
9 + // this crate can write and the vocabulary list does not carry is
10 + // invisible to the dead-vocabulary seal and to the overlap check both.
11 + for fallback in [
12 + Fallback::Wrap,
13 + Fallback::Stack,
14 + Fallback::Shed,
15 + Fallback::Menu,
16 + ] {
17 + assert!(
18 + RUN_CLASSES.contains(&fallback_class(fallback)),
19 + "{fallback:?} is missing from RUN_CLASSES"
20 + );
21 + }
22 + let names = crate::vocabulary::names(&Emit::default());
23 + for name in RUN_CLASSES {
24 + assert!(names.contains(*name), "{name} is not in the vocabulary");
25 + }
26 + }
27 +
28 + #[test]
29 + fn a_run_gives_every_member_a_floor_it_cannot_be_squeezed_below() {
30 + // The whole of what stops the overlap, and it is not a fallback: it
31 + // applies to every run whatever the group declared. flexbox's default
32 + // min-width is auto, which lets an item be compressed below its own
33 + // content in a nowrap row, and that is how a toolbar is drawn over a
34 + // tab strip even with nothing out of flow.
35 + let css = run_rules(&Emit::default());
36 + assert!(css.contains(".run > * {\n min-width: min-content;\n}"));
37 + // No number anywhere in it. The minimum is derived by the browser from
38 + // what the members contain, which is the ruling's own requirement.
39 + assert!(!css.contains("px"));
40 + assert!(!css.contains("rem"));
41 + assert!(!css.contains("@media"));
42 + }
43 +
44 + #[test]
45 + fn room_is_never_asked_of_the_viewport() {
46 + // The 913 case: a window in SizeClass::Expanded holding a group out of
47 + // room. A viewport query answers about the window and would be wrong
48 + // about the group, which is why the table's @media walk is not the
49 + // precedent this follows.
50 + let css = run_rules(&Emit::default());
51 + for size in [SizeClass::Compact, SizeClass::Medium] {
52 + assert!(!css.contains(&size.media_condition()));
53 + }
54 + }
55 +
56 + #[test]
57 + fn every_fallback_lands_as_a_class_and_an_unknown_one_lands_plainly() {
58 + let css = run_rules(&Emit::default());
59 + for fallback in [
60 + Fallback::Wrap,
61 + Fallback::Stack,
62 + Fallback::Shed,
63 + Fallback::Menu,
64 + ] {
65 + let class = fallback_class(fallback);
66 + assert!(css.contains(&format!(".{class} {{")), "{class} unemitted");
67 + }
68 + // Stack is the one that also says what a member does with the line it
69 + // took, which is what separates it from wrapping.
70 + assert!(css.contains(".run-stack > * {\n flex: 1 1 max-content;\n}"));
71 + }
72 +
73 + #[test]
74 + fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
75 + // Balanced Breakfast's styles.css, verbatim. Adoption has to be a
76 + // deletion, not a redesign, or nobody will take it.
77 + let opts = Emit::default();
78 + assert_eq!(
79 + bevel_shadow(Bevel::Raised, &opts),
80 + "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
81 + );
82 + assert_eq!(
83 + bevel_shadow(Bevel::Inset, &opts),
84 + "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
85 + );
86 + }
87 +
88 + #[test]
89 + fn no_colour_ever_reaches_the_output() {
90 + let css = stylesheet(&Emit::default());
91 + assert!(!css.contains('#'), "a hex literal escaped into the CSS");
92 + assert!(
93 + !css.contains("rgb"),
94 + "a colour function escaped into the CSS"
95 + );
96 + // Every colour is named, never resolved.
97 + assert!(css.contains("var(--surface-raised)"));
98 + assert!(css.contains("var(--bevel-light)"));
99 + }
100 +
101 + #[test]
102 + fn a_well_falls_back_through_css_rather_than_through_rust() {
103 + assert_eq!(
104 + fill_var(Fill::Well),
105 + "var(--surface-well, var(--surface-page))"
106 + );
107 + // Nothing else needs one.
108 + assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
109 + assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
110 + }
111 +
112 + #[test]
113 + fn raised_and_well_do_not_collapse_onto_each_other() {
114 + let css = depth_rules(&Emit::default());
115 + assert!(css.contains(".raised {"));
116 + assert!(css.contains(".well {"));
117 + assert!(css.contains("var(--bevel-raised)"));
118 + assert!(css.contains("var(--bevel-inset)"));
119 + }
120 +
121 + /// The cast shadow is composed here from the tone `makeover` derives, so
122 + /// neither crate has to hold the other's numbers.
123 + ///
124 + /// It is a `:root` property and deliberately not a depth class. There is no
125 + /// `Depth::Overlay` in the description layer, and adding one would be a
126 + /// claim about what a screen means rather than about how it is painted;
127 + /// until something asks for it, a consumer names the property on the rule
128 + /// for the menu or the toast it already has.
129 + #[test]
130 + fn the_cast_shadow_is_a_root_property_not_a_depth() {
131 + let css = bevel_properties(&Emit::default());
132 + assert!(css.contains("--elevation-overlay:"));
133 + assert!(css.contains("var(--elevation)"));
134 + assert!(
135 + !depth_rules(&Emit::default()).contains("elevation"),
136 + "elevation is not a depth class"
137 + );
138 + }
139 +
140 + #[test]
141 + fn the_cascade_carries_the_pressed_state() {
142 + let css = surface_rules(&Emit::default());
143 + // The one thing this renderer gets free that the other two resolve by
144 + // hand, eighteen call sites deep in audiofiles' case. Asserted on a
145 + // named surface: pressing belongs to the control, not to the depth.
146 + assert!(css.contains(".card:active {"));
147 + assert!(css.contains(".button:active {"));
148 + }
149 +
150 + #[test]
151 + fn the_depth_class_is_a_surface_and_not_a_control() {
152 + let css = depth_rules(&Emit::default());
153 + // The static surface the vocabulary was missing. Sixteen goingson
154 + // elements wore .card and cancelled its hover and press to get this,
155 + // because a raised object that is not pressable had no other spelling.
156 + for state in [":hover", ":active", ":focus-visible", ":disabled"] {
157 + assert!(
158 + !css.contains(&format!(".raised{state}")),
159 + "the depth class claimed {state}: {css}"
160 + );
161 + }
162 + assert!(css.contains("var(--bevel-raised)"), "still raised: {css}");
163 + }
164 +
165 + #[test]
166 + fn pressing_moves_the_fill_and_not_only_the_edge() {
167 + // The decision-1 guard, and the regression that mattered: emitting the
168 + // bevel flip alone is what left goingson hand-writing `background:
169 + // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
170 + // of the three could be deleted.
171 + let pressed = interactive_rules("button", Depth::Raised, &Emit::default());
172 + assert!(pressed.contains(".button:active {"));
173 + assert!(
174 + pressed.contains("background: var(--surface-well, var(--surface-page))"),
175 + "pressed dropped its fill: {pressed}"
176 + );
177 + assert!(pressed.contains("box-shadow: var(--bevel-inset)"));
178 + }
179 +
180 + #[test]
181 + fn pressed_takes_its_fill_from_the_description_not_from_the_app() {
182 + // goingson presses to --surface-sunken. The description says a pressed
183 + // raised region reads as a well, and makeover says outright that
184 + // surface-sunken cannot serve as one, so the app is the thing that
185 + // moves.
186 + //
187 + // Scoped to the pressed rules rather than to the whole sheet: since
188 + // makeover-layout 0.3.0 an unchosen tab is legitimately
189 + // --surface-sunken, so the token appearing somewhere in the output no
190 + // longer means the app's choice leaked in.
191 + let css = stylesheet(&Emit::default());
192 + let mut checked = 0;
193 + for rule in css.split("}\n") {
194 + if !rule.contains(":active") {
195 + continue;
196 + }
197 + checked += 1;
198 + assert!(
199 + !rule.contains("surface-sunken"),
200 + "a pressed rule took the app's fill: {rule}"
201 + );
202 + }
203 + assert!(checked > 0, "no pressed rules found to check");
204 + assert_eq!(
205 + Depth::Raised.pressed().fill(),
206 + Some(Fill::Well),
207 + "the description changed under us"
208 + );
209 + }
210 +
211 + #[test]
212 + fn the_whole_stylesheet_is_emitted_in_the_family_layer() {
213 + // The point of 0.11.0. Unlayered normal declarations outrank every
214 + // named layer, so an app declaring `@layer base, components` loses
215 + // every rule it owns to this file until this file is layered too.
216 + let css = stylesheet(&Emit::default());
217 + assert!(css.contains(&format!("@layer {CSS_LAYER} {{")));
218 +
219 + // Exactly one layer block, and nothing outside it but the banner.
220 + assert_eq!(css.matches("@layer").count(), 2, "banner names it once");
221 + let opened = css.find("@layer makeover {").expect("layer opens");
222 + for (i, line) in css.lines().enumerate() {
223 + let before_layer = css.lines().take(i).map(str::len).sum::<usize>() < opened;
224 + if before_layer || line.is_empty() {
225 + continue;
226 + }
227 + assert!(
228 + line.starts_with(" ") || line == "}" || line.starts_with(" "),
229 + "line outside the layer: {line:?}"
230 + );
231 + }
232 + }
233 +
234 + #[test]
235 + fn the_generated_sheet_carries_no_trailing_whitespace() {
236 + // A checked-in generated file that a formatter wants to rewrite is a
237 + // diff every time somebody saves it.
238 + let css = stylesheet(&Emit::default());
239 + for (i, line) in css.lines().enumerate() {
240 + assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}");
241 + }
242 + }
243 +
244 + #[test]
245 + fn the_banner_tells_an_app_how_to_order_the_layer() {
246 + // Without a declared order the layer's position depends on which
247 + // generated file the browser sees first, which is not a contract.
248 + let css = stylesheet(&Emit::default());
249 + assert!(css.contains("@layer makeover, base, components, responsive;"));
250 + // And the banner is outside the layer, not a rule inside it.
251 + assert!(css.starts_with("/* Generated by makeover-webview"));
252 + }
253 +
254 + #[test]
255 + fn the_banner_names_the_emitter_so_a_stale_pin_is_visible_on_sight() {
256 + // A consumer whose lockfile pins an old version gets a well-formed
257 + // sheet with components missing and no error. balanced_breakfast ran
258 + // on 657 bytes from a 0.1.0 emitter while its manifest asked for
259 + // 0.5.1, and the only way it surfaced was diffing two apps' generated
260 + // files. The version and the count are what the file says instead.
261 + let css = stylesheet(&Emit::default());
262 + let banner = css.lines().next().unwrap();
263 + assert!(
264 + banner.contains(VERSION),
265 + "{banner} does not name the emitter"
266 + );
267 + let classes = vocabulary::classes_in_css(&css).len();
268 + assert!(classes > 0);
269 + assert!(
270 + banner.contains(&format!("{classes} classes")),
271 + "{banner} does not carry the class count"
272 + );
273 + }
274 +
275 + #[test]
276 + fn a_primitive_owns_every_state_it_implies() {
277 + // The whole point of 0.10.0. Anything emitting a hover rule owes the
278 + // other three, or the consuming app supplies them by out-specifying a
279 + // rule it does not own: 19 such rules in goingson, 21 in the MNW
280 + // server, and three focus rings that do not match.
281 + let css = stylesheet(&Emit::default());
282 + for selector in ["button", "card", "chip", "tab", "segment", "toggle"] {
283 + assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}");
284 + assert!(
285 + css.contains(&format!(".{selector}:active {{")),
286 + "{selector}"
287 + );
288 + assert!(
289 + css.contains(&format!(".{selector}:focus-visible {{")),
290 + "{selector} has no focus ring"
291 + );
292 + assert!(
293 + css.contains(&format!(".{selector}:disabled,")),
294 + "{selector} has no disabled state"
295 + );
296 + }
297 + }
298 +
299 + #[test]
300 + fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() {
301 + // A text field does not light up under the pointer, so it gets the two
302 + // states it has and not the two it does not.
303 + let css = stylesheet(&Emit::default());
304 + assert!(css.contains(".field:focus-visible {"));
305 + assert!(css.contains(".field:disabled,"));
306 + assert!(!css.contains(".field:hover {"));
307 + assert!(!css.contains(".field:active {"));
308 + }
309 +
310 + #[test]
311 + fn disabled_is_emitted_after_hover_so_source_order_settles_it() {
312 + // Every one of these selectors is specificity (0,2,0), so nothing but
313 + // order decides which wins. A disabled button taking the hover fill is
314 + // the exact bug goingson's `.button:disabled:hover` was written to fix,
315 + // and the reason it had to reach (0,3,0) to do it.
316 + let css = interactive_rules("button", Depth::Raised, &Emit::default());
317 + let hover = css.find(":hover").expect("hover");
318 + let active = css.find(":active").expect("active");
319 + let focus = css.find(":focus-visible").expect("focus");
320 + let disabled = css.find(":disabled").expect("disabled");
321 + assert!(hover < active && active < focus && focus < disabled);
322 +
323 + // And it restores the surface, or the hover fill survives underneath.
324 + let tail = &css[disabled..];
325 + assert!(tail.contains("background: var(--surface-raised)"));
326 + }
327 +
328 + #[test]
329 + fn a_flat_control_takes_its_hover_fill_back_when_it_stops_answering() {
330 + // The same contest one depth over, and the half `depth_declarations`
331 + // could not state. Flat declares neither axis, so before 0.68.0 the
332 + // disabled rule won on source order with nothing to say and the hover
333 + // surface stayed under a control that had stopped answering. Reaches
334 + // both facet arms and a suggestion entry.
335 + for depth in [Depth::Flat, Depth::Sunken, Depth::Overlay] {
336 + let css = disabled_rule("x", depth);
337 + assert!(css.contains("box-shadow"), "{depth:?}: {css}");
338 + }
339 + let flat = disabled_rule("x", Depth::Flat);
340 + assert!(flat.contains("background: none;"), "{flat}");
341 + assert!(flat.contains("box-shadow: none;"), "{flat}");
342 +
343 + // Every rule the caller emits states both axes now, so whichever wins
344 + // the source-order contest leaves nothing of the one below it.
345 + let css = interactive_rules("x", Depth::Flat, &Emit::default());
346 + let disabled = css.find(":disabled").expect("disabled");
347 + assert!(css[disabled..].contains("background: none;"), "{css}");
348 + }
349 +
350 + #[test]
351 + fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
352 + // `:disabled` matches form elements only, and a chip is a div. Keying
353 + // on the ARIA attribute too is the pattern the invalid field already
354 + // set: one fact, read by the styling and the accessibility tree alike.
355 + let css = disabled_rule("chip", Depth::Raised);
356 + assert!(css.contains(".chip:disabled,"));
357 + assert!(css.contains(".chip[aria-disabled=\"true\"]"));
358 + assert!(css.contains("cursor: not-allowed"));
359 + }
360 +
361 + #[test]
362 + fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() {
363 + // `outline` has its own property, so unlike the invalid ring there is
364 + // no bevel to restate beside it and nothing to keep in agreement.
365 + let opts = Emit::default();
366 + let css = focus_rule("button", Depth::Raised, &opts);
367 + assert!(css.contains("outline: 2px solid var(--focus-ring)"));
368 + assert!(!css.contains("box-shadow"), "the ring restated the bevel");
369 + }
370 +
371 + #[test]
372 + fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() {
373 + // One ring, placed by depth. The offset comes off `Depth::bevel` and
374 + // not off a per-component choice, which is what gave three apps three
375 + // different rings.
376 + let opts = Emit::default();
377 + assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)"));
378 + assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px"));
379 + // Nothing to sit inside of, so it sits outside.
380 + assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px"));
381 +
382 + // And the ring is not the bevel. Reusing border_width emitted a 1px
383 + // ring that every consumer had already overridden.
384 + assert_ne!(opts.focus_width, opts.border_width);
385 + }
386 +
387 + #[test]
388 + fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() {
389 + // goingson's section 60 exists only to take back the hover state this
390 + // crate handed it. Gating at the source is what deletes that section
391 + // in all three apps rather than having each fight for it.
392 + let css = stylesheet(&Emit::default());
393 + let condition = format!("@media {}", Density::Pointer.media_condition());
394 + assert!(css.contains(&condition));
395 +
396 + // What is gated is every hover state the surfaces carry. The row's
397 + // actions used to be the other half of this test and are not gated any
398 + // more, because they are not hidden any more: a rule that reveals
399 + // nothing needs no capability answer.
400 + let gated: Vec<&str> = css.lines().filter(|line| line.contains(":hover")).collect();
401 + assert!(!gated.is_empty(), "{css}");
402 + for line in gated {
403 + let indent = line.len() - line.trim_start().len();
404 + assert!(indent > 4, "an ungated hover rule: {line}");
405 + }
406 + assert!(!css.contains(".row:hover"), "{css}");
407 + }
408 +
409 + #[test]
410 + fn the_capability_answer_is_asked_for_and_not_assumed() {
411 + // Both halves come from the crates that own them. If `makeover-touch`
412 + // ever says a fingertip has hover, this stops gating on its own.
413 + assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact));
414 + assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact));
415 + assert_eq!(hover_condition(), Some(Density::Pointer.media_condition()));
416 +
417 + // And the size class passed to that call is not a claim about width.
418 + assert!(Affordance::Hover.reads_density());
419 + for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] {
420 + assert!(!Affordance::Hover.available(Density::Touch, size));
421 + }
422 + }
423 +
424 + #[test]
425 + fn hover_resolves_against_the_token_makeover_already_derives() {
426 + let css = interactive_rules("card", Depth::Raised, &Emit::default());
427 + assert!(css.contains(".card:hover {"));
428 + assert!(css.contains("background: var(--hover-surface)"));
429 + // Not the app's choice, which was --surface-overlay.
430 + assert!(!css.contains("surface-overlay"));
431 + }
432 +
433 + #[test]
434 + fn a_badge_gets_no_edge_and_no_fill() {
435 + // Decision 2, and the one visible redesign in phase A. Token::Badge is
436 + // Flat: an edge on a label says it can be pressed.
437 + let css = token_rules(&Emit::default());
438 + let badge = css
439 + .lines()
440 + .skip_while(|l| !l.starts_with(".badge {"))
441 + .take_while(|l| !l.starts_with('}'))
442 + .collect::<Vec<_>>()
443 + .join("\n");
444 + assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}");
445 + assert!(!badge.contains("background"), "badge kept a fill: {badge}");
446 + assert_eq!(Token::Badge.depth(false), Depth::Flat);
447 + assert_eq!(Token::Badge.depth(true), Depth::Flat);
448 + }
449 +
450 + #[test]
451 + fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() {
452 + let css = token_rules(&Emit::default());
453 + // Neutral is the absence of a status, not a status named "none".
454 + assert!(css.contains(".badge {\n color: var(--content-muted);"));
455 + assert!(!css.contains("data-tone=\"content-muted\""));
456 + for tone in ["info", "success", "warning", "danger"] {
457 + assert!(
458 + css.contains(&format!(".badge[data-tone=\"{tone}\"]")),
459 + "missing tone {tone}"
460 + );
461 + assert!(css.contains(&format!("color: var(--{tone})")));
462 + }
463 + }
464 +
465 + #[test]
466 + fn a_chip_is_raised_and_latches_into_a_well() {
467 + let css = token_rules(&Emit::default());
468 + assert!(css.contains(".chip {"));
469 + assert!(css.contains(".chip.latched {"));
470 + assert!(css.contains(".chip:active {"));
471 + // The whole difference from a badge: it answers a click.
472 + assert!(Token::Chip { removable: false }.interactive());
473 + assert!(!Token::Badge.interactive());
474 + }
475 +
476 + #[test]
477 + fn only_a_tab_comes_forward_when_chosen() {
478 + // The folder semantic. Collapsing the three selectors would lose it.
479 + let css = selector_rules(&Emit::default());
480 + assert!(css.contains(".tab.chosen {"));
481 + assert!(css.contains(".segment.chosen {"));
482 + assert!(css.contains(".toggle.chosen {"));
483 + assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
484 + assert_eq!(Selector::Segmented.chosen(), Depth::Well);
485 + assert_eq!(Selector::Toggle.chosen(), Depth::Well);
486 +
487 + let tab = css
488 + .lines()
489 + .skip_while(|l| !l.starts_with(".tab.chosen {"))
490 + .take_while(|l| !l.starts_with('}'))
491 + .collect::<Vec<_>>()
492 + .join("\n");
493 + assert!(
494 + tab.contains("var(--bevel-raised)"),
495 + "tab was held in: {tab}"
496 + );
497 + }
498 +
499 + #[test]
500 + fn an_unchosen_tab_recedes_without_looking_picked() {
Lines truncated