max / makeover-webview
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
4 files changed,
+1000 insertions,
-994 deletions
| @@ -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" onfocus=alert(1) autofocus="\""), | |
| 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("<script>"), "{html}"); | |
| 1465 | - | } | |
| 1466 | - | ||
| 1467 | - | #[test] | |
| 1468 | - | fn every_escaped_sink_is_covered_by_the_one_escaper() { | |
| 1469 | - | assert_eq!(escape("&<>\"'"), "&<>"'"); | |
| 1470 | - | // The character `textContent` serialization leaves alone, which is why | |
| 1471 | - | // the app needs two escapers and this needs one. | |
| 1472 | - | assert!(escape("\"").contains(""")); | |
| 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("<script>"), "{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
| @@ -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" onfocus=alert(1) autofocus="\""), | |
| 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("<script>"), "{html}"); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn every_escaped_sink_is_covered_by_the_one_escaper() { | |
| 72 | + | assert_eq!(escape("&<>\"'"), "&<>"'"); | |
| 73 | + | // The character `textContent` serialization leaves alone, which is why | |
| 74 | + | // the app needs two escapers and this needs one. | |
| 75 | + | assert!(escape("\"").contains(""")); | |
| 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("<script>"), "{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
| @@ -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