max / makeover-immediate
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
2 files changed,
+500 insertions,
-497 deletions
| @@ -1406,679 +1406,4 @@ | |||
| 1406 | 1406 | } | |
| 1407 | 1407 | ||
| 1408 | 1408 | #[cfg(test)] | |
| 1409 | - | mod tests { | |
| 1410 | - | use super::*; | |
| 1411 | - | ||
| 1412 | - | fn palette(well: Color32) -> Palette { | |
| 1413 | - | Palette { | |
| 1414 | - | page: Color32::from_rgb(1, 1, 1), | |
| 1415 | - | raised: Color32::from_rgb(2, 2, 2), | |
| 1416 | - | overlay: Color32::from_rgb(3, 3, 3), | |
| 1417 | - | well, | |
| 1418 | - | sunken: Color32::from_rgb(4, 4, 4), | |
| 1419 | - | bevel_light: Color32::WHITE, | |
| 1420 | - | bevel_dark: Color32::BLACK, | |
| 1421 | - | elevation: Color32::from_black_alpha(46), | |
| 1422 | - | content: Color32::from_rgb(5, 5, 5), | |
| 1423 | - | content_secondary: Color32::from_rgb(55, 55, 55), | |
| 1424 | - | content_muted: Color32::from_rgb(6, 6, 6), | |
| 1425 | - | action: Color32::from_rgb(7, 7, 7), | |
| 1426 | - | danger: Color32::from_rgb(8, 8, 8), | |
| 1427 | - | success: Color32::from_rgb(9, 9, 9), | |
| 1428 | - | warning: Color32::from_rgb(10, 10, 10), | |
| 1429 | - | info: Color32::from_rgb(11, 11, 11), | |
| 1430 | - | } | |
| 1431 | - | } | |
| 1432 | - | ||
| 1433 | - | /// The cast is egui's own shadow type carrying the theme's tone, which is | |
| 1434 | - | /// the whole of what this crate had to decide for it: unlike a bevel, egui | |
| 1435 | - | /// already knows how to paint one. | |
| 1436 | - | #[test] | |
| 1437 | - | fn a_unit_is_drawn_only_where_the_kind_is_a_quantity() { | |
| 1438 | - | // egui-drawing has no harness here, so what is tested is the decision | |
| 1439 | - | // that precedes it: which fields have a unit to draw at all. The kind | |
| 1440 | - | // half comes from the description rather than from a `matches!` in this | |
| 1441 | - | // crate, which is the drift `FieldKind::measurable` exists to stop. | |
| 1442 | - | let ranged = makeover_layout::Field { | |
| 1443 | - | unit: Some("s"), | |
| 1444 | - | ..makeover_layout::Field::range("attack", "Attack", "0", "5") | |
| 1445 | - | }; | |
| 1446 | - | assert_eq!(unit_of(&ranged), Some("s")); | |
| 1447 | - | ||
| 1448 | - | let typed = makeover_layout::Field { | |
| 1449 | - | unit: Some("ms"), | |
| 1450 | - | ..makeover_layout::Field::new(makeover_layout::FieldKind::Number, "fade", "Fade") | |
| 1451 | - | }; | |
| 1452 | - | assert_eq!(unit_of(&typed), Some("ms")); | |
| 1453 | - | ||
| 1454 | - | let worded = makeover_layout::Field { | |
| 1455 | - | unit: Some("s"), | |
| 1456 | - | ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "name", "Name") | |
| 1457 | - | }; | |
| 1458 | - | assert_eq!(unit_of(&worded), None); | |
| 1459 | - | ||
| 1460 | - | let bare = makeover_layout::Field::range("attack", "Attack", "0", "5"); | |
| 1461 | - | assert_eq!(unit_of(&bare), None); | |
| 1462 | - | } | |
| 1463 | - | ||
| 1464 | - | #[test] | |
| 1465 | - | fn the_cast_hands_egui_the_themes_tone() { | |
| 1466 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1467 | - | let cast = p.cast(); | |
| 1468 | - | assert_eq!(cast.color, p.elevation); | |
| 1469 | - | assert!(cast.blur > 0, "a cast shadow is soft"); | |
| 1470 | - | assert_eq!(cast.offset, [0, 2], "it falls downward and only a little"); | |
| 1471 | - | } | |
| 1472 | - | ||
| 1473 | - | #[test] | |
| 1474 | - | fn a_well_resolves_to_its_own_token() { | |
| 1475 | - | // No substitution left. The page-filled well was a stand-in for a | |
| 1476 | - | // token that did not exist yet; it exists now. | |
| 1477 | - | let w = Color32::from_rgb(9, 9, 9); | |
| 1478 | - | let p = palette(w); | |
| 1479 | - | assert_eq!(p.fill(Fill::Well), Some(w)); | |
| 1480 | - | assert_ne!(p.fill(Fill::Well), Some(p.page)); | |
| 1481 | - | } | |
| 1482 | - | ||
| 1483 | - | #[test] | |
| 1484 | - | fn every_intent_is_a_plain_lookup() { | |
| 1485 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1486 | - | assert_eq!(p.fill(Fill::Page), Some(p.page)); | |
| 1487 | - | assert_eq!(p.fill(Fill::Raised), Some(p.raised)); | |
| 1488 | - | assert_eq!(p.fill(Fill::Overlay), Some(p.overlay)); | |
| 1489 | - | } | |
| 1490 | - | ||
| 1491 | - | /// Sunken is its own colour, not the well's and not the page's. The two | |
| 1492 | - | /// are authored in opposite directions and an earlier cut of the | |
| 1493 | - | /// description conflated them. | |
| 1494 | - | #[test] | |
| 1495 | - | fn sunken_is_neither_the_well_nor_the_page() { | |
| 1496 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1497 | - | assert_eq!(p.fill(Fill::Sunken), Some(p.sunken)); | |
| 1498 | - | assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Well)); | |
| 1499 | - | assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Page)); | |
| 1500 | - | } | |
| 1501 | - | ||
| 1502 | - | #[test] | |
| 1503 | - | fn a_raised_region_never_resolves_to_the_well_fill() { | |
| 1504 | - | // The cross-app bug, asserted at the renderer boundary this time. | |
| 1505 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1506 | - | let raised = Depth::Raised.fill().and_then(|f| p.fill(f)); | |
| 1507 | - | let well = Depth::Well.fill().and_then(|f| p.fill(f)); | |
| 1508 | - | assert_eq!(raised, Some(p.raised)); | |
| 1509 | - | assert_ne!(raised, well); | |
| 1510 | - | } | |
| 1511 | - | ||
| 1512 | - | #[test] | |
| 1513 | - | fn an_overlay_is_cast_onto_the_page_and_takes_no_edge() { | |
| 1514 | - | // makeover-layout 0.14.0 is what made this reachable. The answer was | |
| 1515 | - | // already here at 0.10.0 and the question could not be asked. | |
| 1516 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1517 | - | assert_eq!( | |
| 1518 | - | Depth::Overlay.fill().and_then(|f| p.fill(f)), | |
| 1519 | - | Some(p.overlay) | |
| 1520 | - | ); | |
| 1521 | - | assert_eq!(Depth::Overlay.bevel(), None); | |
| 1522 | - | // The shadow `frame` reaches for is the theme's tone rather than | |
| 1523 | - | // egui's default, which is the whole reason `cast` exists. | |
| 1524 | - | assert_eq!(p.cast().color, p.elevation); | |
| 1525 | - | } | |
| 1526 | - | ||
| 1527 | - | #[test] | |
| 1528 | - | fn the_lit_edge_swaps_when_a_card_is_pressed() { | |
| 1529 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1530 | - | let (tl, _) = Depth::Raised.bevel().unwrap().edges(); | |
| 1531 | - | let (ptl, _) = Depth::Raised.pressed().bevel().unwrap().edges(); | |
| 1532 | - | assert_eq!(p.edge(tl), p.bevel_light); | |
| 1533 | - | assert_eq!(p.edge(ptl), p.bevel_dark); | |
| 1534 | - | } | |
| 1535 | - | ||
| 1536 | - | #[test] | |
| 1537 | - | fn flat_asks_for_neither_fill_nor_edge() { | |
| 1538 | - | assert!(Depth::Flat.fill().is_none()); | |
| 1539 | - | assert!(Depth::Flat.bevel().is_none()); | |
| 1540 | - | } | |
| 1541 | - | ||
| 1542 | - | #[test] | |
| 1543 | - | fn a_select_keeps_a_value_none_of_its_options_carries() { | |
| 1544 | - | // The save-the-wrong-thing bug, asserted at the second renderer so it | |
| 1545 | - | // is not re-found there. goingson's own numbers. | |
| 1546 | - | let options = [ | |
| 1547 | - | Choice::plain("1"), | |
| 1548 | - | Choice::plain("3"), | |
| 1549 | - | Choice::plain("7"), | |
| 1550 | - | Choice::plain("14"), | |
| 1551 | - | ]; | |
| 1552 | - | assert_eq!(shown_label(&options, "10"), "10"); | |
| 1553 | - | // And a value that does match reads as its label, not as itself. | |
| 1554 | - | let spelled = [Choice::new("7", "One week")]; | |
| 1555 | - | assert_eq!(shown_label(&spelled, "7"), "One week"); | |
| 1556 | - | } | |
| 1557 | - | ||
| 1558 | - | #[test] | |
| 1559 | - | fn an_unanswered_chooser_reads_its_ghost_text_and_reads_it_muted() { | |
| 1560 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1561 | - | let options = [Choice::new("sp404", "SP-404")]; | |
| 1562 | - | let field = Field { | |
| 1563 | - | placeholder: Some("Select device..."), | |
| 1564 | - | ..Field::select("device", "Conform for device", &options) | |
| 1565 | - | }; | |
| 1566 | - | ||
| 1567 | - | assert_eq!( | |
| 1568 | - | chosen_text(&field, "", &p), | |
| 1569 | - | ("Select device...", p.content_muted), | |
| 1570 | - | "ghost text is not an answer, so it takes the tone the typed kinds' ghost text does" | |
| 1571 | - | ); | |
| 1572 | - | ||
| 1573 | - | // Answered, and it is the label that reads rather than the value. | |
| 1574 | - | assert_eq!(chosen_text(&field, "sp404", &p), ("SP-404", p.content)); | |
| 1575 | - | } | |
| 1576 | - | ||
| 1577 | - | #[test] | |
| 1578 | - | fn a_wrong_answer_is_not_an_absent_one() { | |
| 1579 | - | // The retention-10 bug and the ghost text meet here: a value no option | |
| 1580 | - | // carries still reads as itself, because the field IS answered and the | |
| 1581 | - | // answer is wrong. Only the empty value is unanswered. | |
| 1582 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1583 | - | let options = [Choice::plain("1"), Choice::plain("7")]; | |
| 1584 | - | let field = Field { | |
| 1585 | - | placeholder: Some("Pick one"), | |
| 1586 | - | ..Field::select("retention", "Keep backups for", &options) | |
| 1587 | - | }; | |
| 1588 | - | ||
| 1589 | - | assert_eq!(chosen_text(&field, "10", &p), ("10", p.content)); | |
| 1590 | - | } | |
| 1591 | - | ||
| 1592 | - | #[test] | |
| 1593 | - | fn a_chooser_with_no_ghost_text_is_unchanged() { | |
| 1594 | - | // The whole change is opt-in from the description. A field that says | |
| 1595 | - | // nothing about its empty state still shows an empty box. | |
| 1596 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1597 | - | let options = [Choice::plain("1")]; | |
| 1598 | - | let field = Field::select("retention", "Keep backups for", &options); | |
| 1599 | - | assert_eq!(chosen_text(&field, "", &p), ("", p.content)); | |
| 1600 | - | } | |
| 1601 | - | ||
| 1602 | - | #[test] | |
| 1603 | - | fn a_range_is_slid_and_a_number_is_typed_into() { | |
| 1604 | - | // The distinction the kind was added for, at the renderer that has to | |
| 1605 | - | // act on it. A well with a figure in it is not a quiet slider. | |
| 1606 | - | assert_eq!(control_shape(FieldKind::Range), Control::Slid); | |
| 1607 | - | assert_eq!(control_shape(FieldKind::Number), Control::Typed); | |
| 1608 | - | } | |
| 1609 | - | ||
| 1610 | - | #[test] | |
| 1611 | - | fn a_range_missing_an_end_falls_back_to_a_well() { | |
| 1612 | - | // egui's `Slider` demands both ends, so inventing one would be this | |
| 1613 | - | // renderer picking bounds the app never stated and the user then | |
| 1614 | - | // dragging against them. A typed number takes every answer the slider | |
| 1615 | - | // would. | |
| 1616 | - | let whole = Field::range("review", "Review above", "0", "1"); | |
| 1617 | - | assert_eq!(shape_of(&whole), Control::Slid); | |
| 1618 | - | ||
| 1619 | - | let half = Field { | |
| 1620 | - | max: Some("1"), | |
| 1621 | - | ..Field::new(FieldKind::Range, "review", "Review above") | |
| 1622 | - | }; | |
| 1623 | - | assert_eq!(shape_of(&half), Control::Typed); | |
| 1624 | - | assert_eq!(extent(&half), None); | |
| 1625 | - | ||
| 1626 | - | // A bound this host cannot read is the same outcome by a different | |
| 1627 | - | // route: the description carries bounds as text because the bound of a | |
| 1628 | - | // date is a date. | |
| 1629 | - | let dated = Field::range("when", "When", "2026-08-01", "2026-08-31"); | |
| 1630 | - | assert_eq!(extent(&dated), None); | |
| 1631 | - | } | |
| 1632 | - | ||
| 1633 | - | #[test] | |
| 1634 | - | fn an_interval_is_its_own_shape_and_not_two_numbers() { | |
| 1635 | - | // The distinction the kind was added for, at the renderer that has to | |
| 1636 | - | // arrange it: two wells stacked are two questions on screen, whatever | |
| 1637 | - | // the description says. | |
| 1638 | - | assert_eq!(control_shape(FieldKind::Interval), Control::Spanned); | |
| 1639 | - | assert_eq!(shape_of(&Field::interval("a", "b", "A")), Control::Spanned); | |
| 1640 | - | ||
| 1641 | - | // Unlike a range, it owes no extent: its bounds are a rule on each end | |
| 1642 | - | // rather than the control, so a missing one is an open end. | |
| 1643 | - | let axis = Field { | |
| 1644 | - | min: Some("0"), | |
| 1645 | - | max: Some("300"), | |
| 1646 | - | ..Field::interval("bpm_min", "bpm_max", "BPM") | |
| 1647 | - | }; | |
| 1648 | - | assert_eq!(shape_of(&axis), Control::Spanned); | |
| 1649 | - | } | |
| 1650 | - | ||
| 1651 | - | #[test] | |
| 1652 | - | fn an_empty_end_reads_as_the_bound_it_stands_for() { | |
| 1653 | - | // Which is what the shipped control did before it was described: an | |
| 1654 | - | // unset minimum sits on the low edge and stores no filter. `DragValue` | |
| 1655 | - | // has no empty state, and a text box instead would cost the app a | |
| 1656 | - | // control on the way into being described. | |
| 1657 | - | let axis = Axis { | |
| 1658 | - | extent: Some(0.0..=300.0), | |
| 1659 | - | step: Some("1"), | |
| 1660 | - | unit: Some("BPM"), | |
| 1661 | - | }; | |
| 1662 | - | assert!((axis.edge(Bound::Low) - 0.0).abs() < f64::EPSILON); | |
| 1663 | - | assert!((axis.edge(Bound::High) - 300.0).abs() < f64::EPSILON); | |
| 1664 | - | ||
| 1665 | - | // With no extent there is no edge to sit on, and a drag box has to | |
| 1666 | - | // start somewhere. The one number this renderer invents, invented where | |
| 1667 | - | // the description declined to say anything. | |
| 1668 | - | let open = Axis { | |
| 1669 | - | extent: None, | |
| 1670 | - | step: None, | |
| 1671 | - | unit: None, | |
| 1672 | - | }; | |
| 1673 | - | assert!((open.edge(Bound::Low) - 0.0).abs() < f64::EPSILON); | |
| 1674 | - | assert!((open.edge(Bound::High) - 0.0).abs() < f64::EPSILON); | |
| 1675 | - | } | |
| 1676 | - | ||
| 1677 | - | #[test] | |
| 1678 | - | fn the_step_decides_how_a_dragged_value_is_written_back() { | |
| 1679 | - | // Without it a 0-to-1 threshold writes back whatever float the drag | |
| 1680 | - | // landed on, which is the host's granularity and is what the | |
| 1681 | - | // description says an absent step means. | |
| 1682 | - | assert_eq!(decimals(None), None); | |
| 1683 | - | assert_eq!(decimals(Some("1")), Some(0)); | |
| 1684 | - | assert_eq!(decimals(Some("0.01")), Some(2)); | |
| 1685 | - | // Trailing zeros are not precision: 0.10 is a one-decimal question. | |
| 1686 | - | assert_eq!(decimals(Some("0.10")), Some(1)); | |
| 1687 | - | } | |
| 1688 | - | ||
| 1689 | - | #[test] | |
| 1690 | - | fn an_unavailable_option_is_drawn_muted_rather_than_dropped() { | |
| 1691 | - | // The tone rule, at the one place it is a claim rather than a | |
| 1692 | - | // preference: this option genuinely will not answer, so muted is the | |
| 1693 | - | // truth. The available ones beside it keep the secondary intent. | |
| 1694 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1695 | - | let options = [ | |
| 1696 | - | Choice::new("chromatic", "Chromatic"), | |
| 1697 | - | Choice::new("multi", "Multi-sample").unless("Drop a second sample."), | |
| 1698 | - | ]; | |
| 1699 | - | assert!(options[0].available()); | |
| 1700 | - | assert!(!options[1].available()); | |
| 1701 | - | assert_eq!( | |
| 1702 | - | option_color("chromatic", options[0].value, &p), | |
| 1703 | - | p.content, | |
| 1704 | - | "the chosen option is the emphasised thing" | |
| 1705 | - | ); | |
| 1706 | - | assert_eq!( | |
| 1707 | - | option_color("chromatic", options[1].value, &p), | |
| 1708 | - | p.content_secondary, | |
| 1709 | - | "and `option_color` never mutes: the unavailable path is what does" | |
| 1710 | - | ); | |
| 1711 | - | } | |
| 1712 | - | ||
| 1713 | - | #[test] | |
| 1714 | - | fn a_closed_chooser_carries_both_of_an_options_extra_lines_in_its_row() { | |
| 1715 | - | // makeover-layout 0.39.0. The radio group has room for a second line | |
| 1716 | - | // and this control does not, so the two draw the same description | |
| 1717 | - | // differently and neither is a preference. | |
| 1718 | - | let plain = Choice::new("24", "Small Files"); | |
| 1719 | - | assert_eq!(combo_row(&plain), "Small Files"); | |
| 1720 | - | ||
| 1721 | - | let detailed = plain.detailing("$24/mo. Fits audio, plugins, binaries."); | |
| 1722 | - | assert_eq!( | |
| 1723 | - | combo_row(&detailed), | |
| 1724 | - | "Small Files $24/mo. Fits audio, plugins, binaries." | |
| 1725 | - | ); | |
| 1726 | - | ||
| 1727 | - | // What it is, then why it cannot be picked. An option carrying both has | |
| 1728 | - | // said two things, and the order is the one the group draws. | |
| 1729 | - | let both = detailed.unless("Sold out."); | |
| 1730 | - | assert_eq!( | |
| 1731 | - | combo_row(&both), | |
| 1732 | - | "Small Files $24/mo. Fits audio, plugins, binaries. Sold out." | |
| 1733 | - | ); | |
| 1734 | - | assert_eq!( | |
| 1735 | - | combo_row(&plain.unless("Sold out.")), | |
| 1736 | - | "Small Files Sold out.", | |
| 1737 | - | "the reason still reads the way it did before the detail existed" | |
| 1738 | - | ); | |
| 1739 | - | } | |
| 1740 | - | ||
| 1741 | - | #[test] | |
| 1742 | - | fn only_a_required_field_is_marked() { | |
| 1743 | - | let style = FieldStyle::default(); | |
| 1744 | - | let plain = Field::new(FieldKind::Text, "title", "Title"); | |
| 1745 | - | assert_eq!(label_text(&plain, &style), "Title"); | |
| 1746 | - | ||
| 1747 | - | let required = Field { | |
| 1748 | - | required: true, | |
| 1749 | - | ..plain | |
| 1750 | - | }; | |
| 1751 | - | assert_eq!(label_text(&required, &style), "Title *"); | |
| 1752 | - | ||
| 1753 | - | // The marker is copy and the app owns it, which is why it is a knob. | |
| 1754 | - | let house = FieldStyle { | |
| 1755 | - | required_marker: "(required)", | |
| 1756 | - | ..style | |
| 1757 | - | }; | |
| 1758 | - | assert_eq!(label_text(&required, &house), "Title (required)"); | |
| 1759 | - | } | |
| 1760 | - | ||
| 1761 | - | #[test] | |
| 1762 | - | fn a_select_and_a_checkbox_are_pressed_and_everything_else_is_typed_into() { | |
| 1763 | - | // What decides whether the control gets a well. A well is for what the | |
| 1764 | - | // user looks into, and only one of these is. | |
| 1765 | - | assert_eq!(control_shape(FieldKind::Select), Control::Chosen); | |
| 1766 | - | // Not the wildcard. A theme picker falling to `Control::Typed` would | |
| 1767 | - | // draw a text box over a resolved list, which is worse than the select | |
| 1768 | - | // every app had before the member existed. | |
| 1769 | - | assert_eq!(control_shape(FieldKind::Theme), Control::Themed); | |
| 1770 | - | assert_eq!(control_shape(FieldKind::Radio), Control::Listed); | |
| 1771 | - | assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled); | |
| 1772 | - | for k in [ | |
| 1773 | - | FieldKind::Text, | |
| 1774 | - | FieldKind::Secret, | |
| 1775 | - | FieldKind::Number, | |
| 1776 | - | FieldKind::Email, | |
| 1777 | - | FieldKind::Url, | |
| 1778 | - | FieldKind::Tel, | |
| 1779 | - | FieldKind::Textarea, | |
| 1780 | - | FieldKind::Rich, | |
| 1781 | - | ] { | |
| 1782 | - | assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into"); | |
| 1783 | - | } | |
| 1784 | - | // And the two multi-line ones get a multi-line edit, which is the half | |
| 1785 | - | // `control_shape` alone does not say: both are typed into, and only one | |
| 1786 | - | // of the two edit shapes can hold a markdown document. | |
| 1787 | - | assert!(FieldKind::Rich.multiline()); | |
| 1788 | - | assert!(FieldKind::Textarea.multiline()); | |
| 1789 | - | assert!(!FieldKind::Text.multiline()); | |
| 1790 | - | } | |
| 1791 | - | ||
| 1792 | - | #[test] | |
| 1793 | - | fn the_two_option_taking_kinds_are_drawn_differently_on_purpose() { | |
| 1794 | - | // The description holds Select and Radio apart, and a renderer that | |
| 1795 | - | // collapsed them would silently answer a question the app did not ask: | |
| 1796 | - | // audiofiles' storage style is irreversible and its alternatives have | |
| 1797 | - | // to be readable without opening anything. Asserting the two shapes | |
| 1798 | - | // differ is asserting that distinction survives the trip. | |
| 1799 | - | assert!(FieldKind::Select.offers_options()); | |
| 1800 | - | assert!(FieldKind::Radio.offers_options()); | |
| 1801 | - | assert_ne!( | |
| 1802 | - | control_shape(FieldKind::Select), | |
| 1803 | - | control_shape(FieldKind::Radio) | |
| 1804 | - | ); | |
| 1805 | - | } | |
| 1806 | - | ||
| 1807 | - | #[test] | |
| 1808 | - | fn an_unchosen_option_is_secondary_and_never_muted() { | |
| 1809 | - | let p = palette(Color32::from_rgb(4, 4, 4)); | |
| 1810 | - | assert_eq!(option_color("wav", "wav", &p), p.content); | |
| 1811 | - | assert_eq!(option_color("wav", "aiff", &p), p.content_secondary); | |
| 1812 | - | // The whole point of the distinction: muted is what Disabled resolves | |
| 1813 | - | // to, so an option wearing it would claim it does not answer a press. | |
| 1814 | - | assert_ne!(option_color("wav", "aiff", &p), p.content_muted); | |
| 1815 | - | } | |
| 1816 | - | ||
| 1817 | - | /// What the accessibility tree says a screen drew, as `(role, name)` pairs. | |
| 1818 | - | /// | |
| 1819 | - | /// egui builds this from the same `WidgetInfo` every widget already reports, | |
| 1820 | - | /// so it is what a screen reader would be handed rather than a second | |
| 1821 | - | /// opinion about it. A name that arrives through `labelled_by` is resolved | |
| 1822 | - | /// the way a client resolves it: the relation names another node, and that | |
| 1823 | - | /// node's text is the control's accessible name. | |
| 1824 | - | fn announced(draw: impl FnMut(&mut egui::Ui)) -> Vec<(egui::accesskit::Role, String)> { | |
| 1825 | - | let ctx = egui::Context::default(); | |
| 1826 | - | ctx.enable_accesskit(); | |
| 1827 | - | let mut draw = draw; | |
| 1828 | - | let input = || egui::RawInput { | |
| 1829 | - | screen_rect: Some(egui::Rect::from_min_size( | |
| 1830 | - | egui::Pos2::ZERO, | |
| 1831 | - | egui::vec2(800.0, 600.0), | |
| 1832 | - | )), | |
| 1833 | - | ..Default::default() | |
| 1834 | - | }; | |
| 1835 | - | // Two passes: egui lays out against the previous frame, so the first | |
| 1836 | - | // sees widgets at the wrong rect. | |
| 1837 | - | let _ = ctx.run_ui(input(), &mut draw); | |
| 1838 | - | let out = ctx.run_ui(input(), &mut draw); | |
| 1839 | - | ||
| 1840 | - | let update = out | |
| 1841 | - | .platform_output | |
| 1842 | - | .accesskit_update | |
| 1843 | - | .expect("accesskit is on, so a tree was built"); | |
| 1844 | - | let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect(); | |
| 1845 | - | update | |
| 1846 | - | .nodes | |
| 1847 | - | .iter() | |
| 1848 | - | .map(|(_, node)| { | |
| 1849 | - | let named = node.label().map(str::to_owned).or_else(|| { | |
| 1850 | - | node.labelled_by() | |
| 1851 | - | .iter() | |
| 1852 | - | .find_map(|id| by_id.get(id)) | |
| 1853 | - | .and_then(|by| by.label().or_else(|| by.value()).map(str::to_owned)) | |
| 1854 | - | }); | |
| 1855 | - | (node.role(), named.unwrap_or_default()) | |
| 1856 | - | }) | |
| 1857 | - | .collect() | |
| 1858 | - | } | |
| 1859 | - | ||
| 1860 | - | #[test] | |
| 1861 | - | fn a_fields_label_names_its_control_rather_than_sitting_beside_it() { | |
| 1862 | - | let f = Field::new(FieldKind::Text, "name", "Vault name"); | |
| 1863 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1864 | - | let drawn = announced(|ui| { | |
| 1865 | - | let mut text = String::new(); | |
| 1866 | - | field( | |
| 1867 | - | ui, | |
| 1868 | - | &f, | |
| 1869 | - | Filling::Text(&mut text), | |
| 1870 | - | None, | |
| 1871 | - | &p, | |
| 1872 | - | &FieldStyle::default(), | |
| 1873 | - | ); | |
| 1874 | - | }); | |
| 1875 | - | ||
| 1876 | - | let box_ = drawn | |
| 1877 | - | .iter() | |
| 1878 | - | .find(|(role, _)| *role == egui::accesskit::Role::TextInput) | |
| 1879 | - | .expect("a text field draws a text input"); | |
| 1880 | - | assert_eq!( | |
| 1881 | - | box_.1, "Vault name", | |
| 1882 | - | "the box is announced by the question rather than unnamed: {drawn:?}" | |
| 1883 | - | ); | |
| 1884 | - | } | |
| 1885 | - | ||
| 1886 | - | #[test] | |
| 1887 | - | fn a_prefilled_box_is_still_announced_by_its_question() { | |
| 1888 | - | // The sharper half. With nothing attached, a box carrying a value is | |
| 1889 | - | // announced as that value, so a rename field read out the name it was | |
| 1890 | - | // seeded with and never said what was being asked. | |
| 1891 | - | let f = Field::new(FieldKind::Text, "name", "New name"); | |
| 1892 | - | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 1893 | - | let drawn = announced(|ui| { | |
| 1894 | - | let mut text = String::from("Drums"); | |
| 1895 | - | field( | |
| 1896 | - | ui, | |
| 1897 | - | &f, | |
| 1898 | - | Filling::Text(&mut text), | |
| 1899 | - | None, | |
| 1900 | - | &p, | |
| 1901 | - | &FieldStyle::default(), | |
| 1902 | - | ); | |
| 1903 | - | }); | |
| 1904 | - | ||
| 1905 | - | let box_ = drawn |
Lines truncated
| @@ -1,0 +1,676 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | fn palette(well: Color32) -> Palette { | |
| 6 | + | Palette { | |
| 7 | + | page: Color32::from_rgb(1, 1, 1), | |
| 8 | + | raised: Color32::from_rgb(2, 2, 2), | |
| 9 | + | overlay: Color32::from_rgb(3, 3, 3), | |
| 10 | + | well, | |
| 11 | + | sunken: Color32::from_rgb(4, 4, 4), | |
| 12 | + | bevel_light: Color32::WHITE, | |
| 13 | + | bevel_dark: Color32::BLACK, | |
| 14 | + | elevation: Color32::from_black_alpha(46), | |
| 15 | + | content: Color32::from_rgb(5, 5, 5), | |
| 16 | + | content_secondary: Color32::from_rgb(55, 55, 55), | |
| 17 | + | content_muted: Color32::from_rgb(6, 6, 6), | |
| 18 | + | action: Color32::from_rgb(7, 7, 7), | |
| 19 | + | danger: Color32::from_rgb(8, 8, 8), | |
| 20 | + | success: Color32::from_rgb(9, 9, 9), | |
| 21 | + | warning: Color32::from_rgb(10, 10, 10), | |
| 22 | + | info: Color32::from_rgb(11, 11, 11), | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// The cast is egui's own shadow type carrying the theme's tone, which is | |
| 27 | + | /// the whole of what this crate had to decide for it: unlike a bevel, egui | |
| 28 | + | /// already knows how to paint one. | |
| 29 | + | #[test] | |
| 30 | + | fn a_unit_is_drawn_only_where_the_kind_is_a_quantity() { | |
| 31 | + | // egui-drawing has no harness here, so what is tested is the decision | |
| 32 | + | // that precedes it: which fields have a unit to draw at all. The kind | |
| 33 | + | // half comes from the description rather than from a `matches!` in this | |
| 34 | + | // crate, which is the drift `FieldKind::measurable` exists to stop. | |
| 35 | + | let ranged = makeover_layout::Field { | |
| 36 | + | unit: Some("s"), | |
| 37 | + | ..makeover_layout::Field::range("attack", "Attack", "0", "5") | |
| 38 | + | }; | |
| 39 | + | assert_eq!(unit_of(&ranged), Some("s")); | |
| 40 | + | ||
| 41 | + | let typed = makeover_layout::Field { | |
| 42 | + | unit: Some("ms"), | |
| 43 | + | ..makeover_layout::Field::new(makeover_layout::FieldKind::Number, "fade", "Fade") | |
| 44 | + | }; | |
| 45 | + | assert_eq!(unit_of(&typed), Some("ms")); | |
| 46 | + | ||
| 47 | + | let worded = makeover_layout::Field { | |
| 48 | + | unit: Some("s"), | |
| 49 | + | ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "name", "Name") | |
| 50 | + | }; | |
| 51 | + | assert_eq!(unit_of(&worded), None); | |
| 52 | + | ||
| 53 | + | let bare = makeover_layout::Field::range("attack", "Attack", "0", "5"); | |
| 54 | + | assert_eq!(unit_of(&bare), None); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn the_cast_hands_egui_the_themes_tone() { | |
| 59 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 60 | + | let cast = p.cast(); | |
| 61 | + | assert_eq!(cast.color, p.elevation); | |
| 62 | + | assert!(cast.blur > 0, "a cast shadow is soft"); | |
| 63 | + | assert_eq!(cast.offset, [0, 2], "it falls downward and only a little"); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | #[test] | |
| 67 | + | fn a_well_resolves_to_its_own_token() { | |
| 68 | + | // No substitution left. The page-filled well was a stand-in for a | |
| 69 | + | // token that did not exist yet; it exists now. | |
| 70 | + | let w = Color32::from_rgb(9, 9, 9); | |
| 71 | + | let p = palette(w); | |
| 72 | + | assert_eq!(p.fill(Fill::Well), Some(w)); | |
| 73 | + | assert_ne!(p.fill(Fill::Well), Some(p.page)); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[test] | |
| 77 | + | fn every_intent_is_a_plain_lookup() { | |
| 78 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 79 | + | assert_eq!(p.fill(Fill::Page), Some(p.page)); | |
| 80 | + | assert_eq!(p.fill(Fill::Raised), Some(p.raised)); | |
| 81 | + | assert_eq!(p.fill(Fill::Overlay), Some(p.overlay)); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Sunken is its own colour, not the well's and not the page's. The two | |
| 85 | + | /// are authored in opposite directions and an earlier cut of the | |
| 86 | + | /// description conflated them. | |
| 87 | + | #[test] | |
| 88 | + | fn sunken_is_neither_the_well_nor_the_page() { | |
| 89 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 90 | + | assert_eq!(p.fill(Fill::Sunken), Some(p.sunken)); | |
| 91 | + | assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Well)); | |
| 92 | + | assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Page)); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn a_raised_region_never_resolves_to_the_well_fill() { | |
| 97 | + | // The cross-app bug, asserted at the renderer boundary this time. | |
| 98 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 99 | + | let raised = Depth::Raised.fill().and_then(|f| p.fill(f)); | |
| 100 | + | let well = Depth::Well.fill().and_then(|f| p.fill(f)); | |
| 101 | + | assert_eq!(raised, Some(p.raised)); | |
| 102 | + | assert_ne!(raised, well); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[test] | |
| 106 | + | fn an_overlay_is_cast_onto_the_page_and_takes_no_edge() { | |
| 107 | + | // makeover-layout 0.14.0 is what made this reachable. The answer was | |
| 108 | + | // already here at 0.10.0 and the question could not be asked. | |
| 109 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 110 | + | assert_eq!( | |
| 111 | + | Depth::Overlay.fill().and_then(|f| p.fill(f)), | |
| 112 | + | Some(p.overlay) | |
| 113 | + | ); | |
| 114 | + | assert_eq!(Depth::Overlay.bevel(), None); | |
| 115 | + | // The shadow `frame` reaches for is the theme's tone rather than | |
| 116 | + | // egui's default, which is the whole reason `cast` exists. | |
| 117 | + | assert_eq!(p.cast().color, p.elevation); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[test] | |
| 121 | + | fn the_lit_edge_swaps_when_a_card_is_pressed() { | |
| 122 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 123 | + | let (tl, _) = Depth::Raised.bevel().unwrap().edges(); | |
| 124 | + | let (ptl, _) = Depth::Raised.pressed().bevel().unwrap().edges(); | |
| 125 | + | assert_eq!(p.edge(tl), p.bevel_light); | |
| 126 | + | assert_eq!(p.edge(ptl), p.bevel_dark); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn flat_asks_for_neither_fill_nor_edge() { | |
| 131 | + | assert!(Depth::Flat.fill().is_none()); | |
| 132 | + | assert!(Depth::Flat.bevel().is_none()); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[test] | |
| 136 | + | fn a_select_keeps_a_value_none_of_its_options_carries() { | |
| 137 | + | // The save-the-wrong-thing bug, asserted at the second renderer so it | |
| 138 | + | // is not re-found there. goingson's own numbers. | |
| 139 | + | let options = [ | |
| 140 | + | Choice::plain("1"), | |
| 141 | + | Choice::plain("3"), | |
| 142 | + | Choice::plain("7"), | |
| 143 | + | Choice::plain("14"), | |
| 144 | + | ]; | |
| 145 | + | assert_eq!(shown_label(&options, "10"), "10"); | |
| 146 | + | // And a value that does match reads as its label, not as itself. | |
| 147 | + | let spelled = [Choice::new("7", "One week")]; | |
| 148 | + | assert_eq!(shown_label(&spelled, "7"), "One week"); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn an_unanswered_chooser_reads_its_ghost_text_and_reads_it_muted() { | |
| 153 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 154 | + | let options = [Choice::new("sp404", "SP-404")]; | |
| 155 | + | let field = Field { | |
| 156 | + | placeholder: Some("Select device..."), | |
| 157 | + | ..Field::select("device", "Conform for device", &options) | |
| 158 | + | }; | |
| 159 | + | ||
| 160 | + | assert_eq!( | |
| 161 | + | chosen_text(&field, "", &p), | |
| 162 | + | ("Select device...", p.content_muted), | |
| 163 | + | "ghost text is not an answer, so it takes the tone the typed kinds' ghost text does" | |
| 164 | + | ); | |
| 165 | + | ||
| 166 | + | // Answered, and it is the label that reads rather than the value. | |
| 167 | + | assert_eq!(chosen_text(&field, "sp404", &p), ("SP-404", p.content)); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn a_wrong_answer_is_not_an_absent_one() { | |
| 172 | + | // The retention-10 bug and the ghost text meet here: a value no option | |
| 173 | + | // carries still reads as itself, because the field IS answered and the | |
| 174 | + | // answer is wrong. Only the empty value is unanswered. | |
| 175 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 176 | + | let options = [Choice::plain("1"), Choice::plain("7")]; | |
| 177 | + | let field = Field { | |
| 178 | + | placeholder: Some("Pick one"), | |
| 179 | + | ..Field::select("retention", "Keep backups for", &options) | |
| 180 | + | }; | |
| 181 | + | ||
| 182 | + | assert_eq!(chosen_text(&field, "10", &p), ("10", p.content)); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | #[test] | |
| 186 | + | fn a_chooser_with_no_ghost_text_is_unchanged() { | |
| 187 | + | // The whole change is opt-in from the description. A field that says | |
| 188 | + | // nothing about its empty state still shows an empty box. | |
| 189 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 190 | + | let options = [Choice::plain("1")]; | |
| 191 | + | let field = Field::select("retention", "Keep backups for", &options); | |
| 192 | + | assert_eq!(chosen_text(&field, "", &p), ("", p.content)); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[test] | |
| 196 | + | fn a_range_is_slid_and_a_number_is_typed_into() { | |
| 197 | + | // The distinction the kind was added for, at the renderer that has to | |
| 198 | + | // act on it. A well with a figure in it is not a quiet slider. | |
| 199 | + | assert_eq!(control_shape(FieldKind::Range), Control::Slid); | |
| 200 | + | assert_eq!(control_shape(FieldKind::Number), Control::Typed); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[test] | |
| 204 | + | fn a_range_missing_an_end_falls_back_to_a_well() { | |
| 205 | + | // egui's `Slider` demands both ends, so inventing one would be this | |
| 206 | + | // renderer picking bounds the app never stated and the user then | |
| 207 | + | // dragging against them. A typed number takes every answer the slider | |
| 208 | + | // would. | |
| 209 | + | let whole = Field::range("review", "Review above", "0", "1"); | |
| 210 | + | assert_eq!(shape_of(&whole), Control::Slid); | |
| 211 | + | ||
| 212 | + | let half = Field { | |
| 213 | + | max: Some("1"), | |
| 214 | + | ..Field::new(FieldKind::Range, "review", "Review above") | |
| 215 | + | }; | |
| 216 | + | assert_eq!(shape_of(&half), Control::Typed); | |
| 217 | + | assert_eq!(extent(&half), None); | |
| 218 | + | ||
| 219 | + | // A bound this host cannot read is the same outcome by a different | |
| 220 | + | // route: the description carries bounds as text because the bound of a | |
| 221 | + | // date is a date. | |
| 222 | + | let dated = Field::range("when", "When", "2026-08-01", "2026-08-31"); | |
| 223 | + | assert_eq!(extent(&dated), None); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn an_interval_is_its_own_shape_and_not_two_numbers() { | |
| 228 | + | // The distinction the kind was added for, at the renderer that has to | |
| 229 | + | // arrange it: two wells stacked are two questions on screen, whatever | |
| 230 | + | // the description says. | |
| 231 | + | assert_eq!(control_shape(FieldKind::Interval), Control::Spanned); | |
| 232 | + | assert_eq!(shape_of(&Field::interval("a", "b", "A")), Control::Spanned); | |
| 233 | + | ||
| 234 | + | // Unlike a range, it owes no extent: its bounds are a rule on each end | |
| 235 | + | // rather than the control, so a missing one is an open end. | |
| 236 | + | let axis = Field { | |
| 237 | + | min: Some("0"), | |
| 238 | + | max: Some("300"), | |
| 239 | + | ..Field::interval("bpm_min", "bpm_max", "BPM") | |
| 240 | + | }; | |
| 241 | + | assert_eq!(shape_of(&axis), Control::Spanned); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn an_empty_end_reads_as_the_bound_it_stands_for() { | |
| 246 | + | // Which is what the shipped control did before it was described: an | |
| 247 | + | // unset minimum sits on the low edge and stores no filter. `DragValue` | |
| 248 | + | // has no empty state, and a text box instead would cost the app a | |
| 249 | + | // control on the way into being described. | |
| 250 | + | let axis = Axis { | |
| 251 | + | extent: Some(0.0..=300.0), | |
| 252 | + | step: Some("1"), | |
| 253 | + | unit: Some("BPM"), | |
| 254 | + | }; | |
| 255 | + | assert!((axis.edge(Bound::Low) - 0.0).abs() < f64::EPSILON); | |
| 256 | + | assert!((axis.edge(Bound::High) - 300.0).abs() < f64::EPSILON); | |
| 257 | + | ||
| 258 | + | // With no extent there is no edge to sit on, and a drag box has to | |
| 259 | + | // start somewhere. The one number this renderer invents, invented where | |
| 260 | + | // the description declined to say anything. | |
| 261 | + | let open = Axis { | |
| 262 | + | extent: None, | |
| 263 | + | step: None, | |
| 264 | + | unit: None, | |
| 265 | + | }; | |
| 266 | + | assert!((open.edge(Bound::Low) - 0.0).abs() < f64::EPSILON); | |
| 267 | + | assert!((open.edge(Bound::High) - 0.0).abs() < f64::EPSILON); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn the_step_decides_how_a_dragged_value_is_written_back() { | |
| 272 | + | // Without it a 0-to-1 threshold writes back whatever float the drag | |
| 273 | + | // landed on, which is the host's granularity and is what the | |
| 274 | + | // description says an absent step means. | |
| 275 | + | assert_eq!(decimals(None), None); | |
| 276 | + | assert_eq!(decimals(Some("1")), Some(0)); | |
| 277 | + | assert_eq!(decimals(Some("0.01")), Some(2)); | |
| 278 | + | // Trailing zeros are not precision: 0.10 is a one-decimal question. | |
| 279 | + | assert_eq!(decimals(Some("0.10")), Some(1)); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn an_unavailable_option_is_drawn_muted_rather_than_dropped() { | |
| 284 | + | // The tone rule, at the one place it is a claim rather than a | |
| 285 | + | // preference: this option genuinely will not answer, so muted is the | |
| 286 | + | // truth. The available ones beside it keep the secondary intent. | |
| 287 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 288 | + | let options = [ | |
| 289 | + | Choice::new("chromatic", "Chromatic"), | |
| 290 | + | Choice::new("multi", "Multi-sample").unless("Drop a second sample."), | |
| 291 | + | ]; | |
| 292 | + | assert!(options[0].available()); | |
| 293 | + | assert!(!options[1].available()); | |
| 294 | + | assert_eq!( | |
| 295 | + | option_color("chromatic", options[0].value, &p), | |
| 296 | + | p.content, | |
| 297 | + | "the chosen option is the emphasised thing" | |
| 298 | + | ); | |
| 299 | + | assert_eq!( | |
| 300 | + | option_color("chromatic", options[1].value, &p), | |
| 301 | + | p.content_secondary, | |
| 302 | + | "and `option_color` never mutes: the unavailable path is what does" | |
| 303 | + | ); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn a_closed_chooser_carries_both_of_an_options_extra_lines_in_its_row() { | |
| 308 | + | // makeover-layout 0.39.0. The radio group has room for a second line | |
| 309 | + | // and this control does not, so the two draw the same description | |
| 310 | + | // differently and neither is a preference. | |
| 311 | + | let plain = Choice::new("24", "Small Files"); | |
| 312 | + | assert_eq!(combo_row(&plain), "Small Files"); | |
| 313 | + | ||
| 314 | + | let detailed = plain.detailing("$24/mo. Fits audio, plugins, binaries."); | |
| 315 | + | assert_eq!( | |
| 316 | + | combo_row(&detailed), | |
| 317 | + | "Small Files $24/mo. Fits audio, plugins, binaries." | |
| 318 | + | ); | |
| 319 | + | ||
| 320 | + | // What it is, then why it cannot be picked. An option carrying both has | |
| 321 | + | // said two things, and the order is the one the group draws. | |
| 322 | + | let both = detailed.unless("Sold out."); | |
| 323 | + | assert_eq!( | |
| 324 | + | combo_row(&both), | |
| 325 | + | "Small Files $24/mo. Fits audio, plugins, binaries. Sold out." | |
| 326 | + | ); | |
| 327 | + | assert_eq!( | |
| 328 | + | combo_row(&plain.unless("Sold out.")), | |
| 329 | + | "Small Files Sold out.", | |
| 330 | + | "the reason still reads the way it did before the detail existed" | |
| 331 | + | ); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn only_a_required_field_is_marked() { | |
| 336 | + | let style = FieldStyle::default(); | |
| 337 | + | let plain = Field::new(FieldKind::Text, "title", "Title"); | |
| 338 | + | assert_eq!(label_text(&plain, &style), "Title"); | |
| 339 | + | ||
| 340 | + | let required = Field { | |
| 341 | + | required: true, | |
| 342 | + | ..plain | |
| 343 | + | }; | |
| 344 | + | assert_eq!(label_text(&required, &style), "Title *"); | |
| 345 | + | ||
| 346 | + | // The marker is copy and the app owns it, which is why it is a knob. | |
| 347 | + | let house = FieldStyle { | |
| 348 | + | required_marker: "(required)", | |
| 349 | + | ..style | |
| 350 | + | }; | |
| 351 | + | assert_eq!(label_text(&required, &house), "Title (required)"); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[test] | |
| 355 | + | fn a_select_and_a_checkbox_are_pressed_and_everything_else_is_typed_into() { | |
| 356 | + | // What decides whether the control gets a well. A well is for what the | |
| 357 | + | // user looks into, and only one of these is. | |
| 358 | + | assert_eq!(control_shape(FieldKind::Select), Control::Chosen); | |
| 359 | + | // Not the wildcard. A theme picker falling to `Control::Typed` would | |
| 360 | + | // draw a text box over a resolved list, which is worse than the select | |
| 361 | + | // every app had before the member existed. | |
| 362 | + | assert_eq!(control_shape(FieldKind::Theme), Control::Themed); | |
| 363 | + | assert_eq!(control_shape(FieldKind::Radio), Control::Listed); | |
| 364 | + | assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled); | |
| 365 | + | for k in [ | |
| 366 | + | FieldKind::Text, | |
| 367 | + | FieldKind::Secret, | |
| 368 | + | FieldKind::Number, | |
| 369 | + | FieldKind::Email, | |
| 370 | + | FieldKind::Url, | |
| 371 | + | FieldKind::Tel, | |
| 372 | + | FieldKind::Textarea, | |
| 373 | + | FieldKind::Rich, | |
| 374 | + | ] { | |
| 375 | + | assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into"); | |
| 376 | + | } | |
| 377 | + | // And the two multi-line ones get a multi-line edit, which is the half | |
| 378 | + | // `control_shape` alone does not say: both are typed into, and only one | |
| 379 | + | // of the two edit shapes can hold a markdown document. | |
| 380 | + | assert!(FieldKind::Rich.multiline()); | |
| 381 | + | assert!(FieldKind::Textarea.multiline()); | |
| 382 | + | assert!(!FieldKind::Text.multiline()); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[test] | |
| 386 | + | fn the_two_option_taking_kinds_are_drawn_differently_on_purpose() { | |
| 387 | + | // The description holds Select and Radio apart, and a renderer that | |
| 388 | + | // collapsed them would silently answer a question the app did not ask: | |
| 389 | + | // audiofiles' storage style is irreversible and its alternatives have | |
| 390 | + | // to be readable without opening anything. Asserting the two shapes | |
| 391 | + | // differ is asserting that distinction survives the trip. | |
| 392 | + | assert!(FieldKind::Select.offers_options()); | |
| 393 | + | assert!(FieldKind::Radio.offers_options()); | |
| 394 | + | assert_ne!( | |
| 395 | + | control_shape(FieldKind::Select), | |
| 396 | + | control_shape(FieldKind::Radio) | |
| 397 | + | ); | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | #[test] | |
| 401 | + | fn an_unchosen_option_is_secondary_and_never_muted() { | |
| 402 | + | let p = palette(Color32::from_rgb(4, 4, 4)); | |
| 403 | + | assert_eq!(option_color("wav", "wav", &p), p.content); | |
| 404 | + | assert_eq!(option_color("wav", "aiff", &p), p.content_secondary); | |
| 405 | + | // The whole point of the distinction: muted is what Disabled resolves | |
| 406 | + | // to, so an option wearing it would claim it does not answer a press. | |
| 407 | + | assert_ne!(option_color("wav", "aiff", &p), p.content_muted); | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | /// What the accessibility tree says a screen drew, as `(role, name)` pairs. | |
| 411 | + | /// | |
| 412 | + | /// egui builds this from the same `WidgetInfo` every widget already reports, | |
| 413 | + | /// so it is what a screen reader would be handed rather than a second | |
| 414 | + | /// opinion about it. A name that arrives through `labelled_by` is resolved | |
| 415 | + | /// the way a client resolves it: the relation names another node, and that | |
| 416 | + | /// node's text is the control's accessible name. | |
| 417 | + | fn announced(draw: impl FnMut(&mut egui::Ui)) -> Vec<(egui::accesskit::Role, String)> { | |
| 418 | + | let ctx = egui::Context::default(); | |
| 419 | + | ctx.enable_accesskit(); | |
| 420 | + | let mut draw = draw; | |
| 421 | + | let input = || egui::RawInput { | |
| 422 | + | screen_rect: Some(egui::Rect::from_min_size( | |
| 423 | + | egui::Pos2::ZERO, | |
| 424 | + | egui::vec2(800.0, 600.0), | |
| 425 | + | )), | |
| 426 | + | ..Default::default() | |
| 427 | + | }; | |
| 428 | + | // Two passes: egui lays out against the previous frame, so the first | |
| 429 | + | // sees widgets at the wrong rect. | |
| 430 | + | let _ = ctx.run_ui(input(), &mut draw); | |
| 431 | + | let out = ctx.run_ui(input(), &mut draw); | |
| 432 | + | ||
| 433 | + | let update = out | |
| 434 | + | .platform_output | |
| 435 | + | .accesskit_update | |
| 436 | + | .expect("accesskit is on, so a tree was built"); | |
| 437 | + | let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect(); | |
| 438 | + | update | |
| 439 | + | .nodes | |
| 440 | + | .iter() | |
| 441 | + | .map(|(_, node)| { | |
| 442 | + | let named = node.label().map(str::to_owned).or_else(|| { | |
| 443 | + | node.labelled_by() | |
| 444 | + | .iter() | |
| 445 | + | .find_map(|id| by_id.get(id)) | |
| 446 | + | .and_then(|by| by.label().or_else(|| by.value()).map(str::to_owned)) | |
| 447 | + | }); | |
| 448 | + | (node.role(), named.unwrap_or_default()) | |
| 449 | + | }) | |
| 450 | + | .collect() | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | #[test] | |
| 454 | + | fn a_fields_label_names_its_control_rather_than_sitting_beside_it() { | |
| 455 | + | let f = Field::new(FieldKind::Text, "name", "Vault name"); | |
| 456 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 457 | + | let drawn = announced(|ui| { | |
| 458 | + | let mut text = String::new(); | |
| 459 | + | field( | |
| 460 | + | ui, | |
| 461 | + | &f, | |
| 462 | + | Filling::Text(&mut text), | |
| 463 | + | None, | |
| 464 | + | &p, | |
| 465 | + | &FieldStyle::default(), | |
| 466 | + | ); | |
| 467 | + | }); | |
| 468 | + | ||
| 469 | + | let box_ = drawn | |
| 470 | + | .iter() | |
| 471 | + | .find(|(role, _)| *role == egui::accesskit::Role::TextInput) | |
| 472 | + | .expect("a text field draws a text input"); | |
| 473 | + | assert_eq!( | |
| 474 | + | box_.1, "Vault name", | |
| 475 | + | "the box is announced by the question rather than unnamed: {drawn:?}" | |
| 476 | + | ); | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | #[test] | |
| 480 | + | fn a_prefilled_box_is_still_announced_by_its_question() { | |
| 481 | + | // The sharper half. With nothing attached, a box carrying a value is | |
| 482 | + | // announced as that value, so a rename field read out the name it was | |
| 483 | + | // seeded with and never said what was being asked. | |
| 484 | + | let f = Field::new(FieldKind::Text, "name", "New name"); | |
| 485 | + | let p = palette(Color32::from_rgb(9, 9, 9)); | |
| 486 | + | let drawn = announced(|ui| { | |
| 487 | + | let mut text = String::from("Drums"); | |
| 488 | + | field( | |
| 489 | + | ui, | |
| 490 | + | &f, | |
| 491 | + | Filling::Text(&mut text), | |
| 492 | + | None, | |
| 493 | + | &p, | |
| 494 | + | &FieldStyle::default(), | |
| 495 | + | ); | |
| 496 | + | }); | |
| 497 | + | ||
| 498 | + | let box_ = drawn | |
| 499 | + | .iter() | |
| 500 | + | .find(|(role, _)| *role == egui::accesskit::Role::TextInput) |
Lines truncated