| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use super::{Change, Intent as _, Syntax}; |
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
#[test] |
| 15 |
fn the_names_a_caller_is_told_to_use_are_exported_from_the_root() { |
| 16 |
#[allow(unused_imports)] |
| 17 |
use crate::{ |
| 18 |
Accepted, Act, Arrangement, Awaiting, Bevel, Candidate, CellPart, Change, Choice, Column, |
| 19 |
Contrast, Curve, DATE_FORMAT, DATETIME_FORMAT, Depth, Edge, Extent, Facet, FacetValue, |
| 20 |
Fallback, Family, Field, FieldKind, Figure, Fill, Fit, Flow, Heading, Intent, Loading, |
| 21 |
Measure, Meter, Nesting, Notice, Paging, Placement, Priority, Readiness, Region, RowPart, |
| 22 |
Selecting, Selector, Share, Showing, Sort, Span, Standing, State, Syntax, ThemeChoice, |
| 23 |
ThemeVariant, Token, Tone, Track, Unit, Width, Window, |
| 24 |
}; |
| 25 |
} |
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
#[test] |
| 31 |
fn an_unclassified_run_is_plain_and_plain_is_the_default() { |
| 32 |
assert_eq!(Syntax::default(), Syntax::Plain); |
| 33 |
assert_eq!(Syntax::Plain.name(), "plain"); |
| 34 |
} |
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
#[test] |
| 40 |
fn every_syntax_class_has_its_own_spelling() { |
| 41 |
let all = [ |
| 42 |
Syntax::Plain, |
| 43 |
Syntax::Keyword, |
| 44 |
Syntax::String, |
| 45 |
Syntax::Comment, |
| 46 |
Syntax::Constant, |
| 47 |
Syntax::Entity, |
| 48 |
Syntax::Variable, |
| 49 |
Syntax::Support, |
| 50 |
]; |
| 51 |
let mut seen = std::collections::BTreeSet::new(); |
| 52 |
for class in all { |
| 53 |
assert!(!class.name().is_empty(), "{class:?} has no spelling"); |
| 54 |
assert!(seen.insert(class.name()), "{class:?} shares a spelling"); |
| 55 |
} |
| 56 |
assert_eq!(seen.len(), all.len()); |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
#[test] |
| 62 |
fn an_unmarked_diff_line_is_context() { |
| 63 |
assert_eq!(Change::default(), Change::Context); |
| 64 |
assert_eq!(Change::Context.token(), "content"); |
| 65 |
} |
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
#[test] |
| 70 |
fn the_two_sides_of_a_change_take_the_status_intents() { |
| 71 |
assert_eq!(Change::Added.token(), "success"); |
| 72 |
assert_eq!(Change::Removed.token(), "danger"); |
| 73 |
} |
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
#[test] |
| 79 |
fn nesting_says_how_deep_and_not_how_wide() { |
| 80 |
assert_eq!(Nesting::default(), Nesting::top()); |
| 81 |
assert_eq!(Nesting::top().level, 0); |
| 82 |
assert!(!Nesting::top().is_nested()); |
| 83 |
|
| 84 |
let under = Nesting::at(2); |
| 85 |
assert_eq!(under.level, 2); |
| 86 |
assert!(under.is_nested()); |
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
assert!(Nesting::top() < under); |
| 91 |
} |
| 92 |
|
| 93 |
use super::*; |
| 94 |
|
| 95 |
#[test] |
| 96 |
fn a_markdown_field_is_multiline_and_offers_nothing() { |
| 97 |
|
| 98 |
|
| 99 |
assert!(FieldKind::Rich.multiline()); |
| 100 |
assert!(FieldKind::Textarea.multiline()); |
| 101 |
assert!(!FieldKind::Text.multiline()); |
| 102 |
|
| 103 |
assert!(!FieldKind::Rich.offers_options()); |
| 104 |
assert!(!FieldKind::Rich.temporal()); |
| 105 |
assert!(FieldKind::Rich.visible()); |
| 106 |
} |
| 107 |
|
| 108 |
#[test] |
| 109 |
fn only_the_numeric_kinds_are_measurable() { |
| 110 |
assert!(FieldKind::Number.measurable()); |
| 111 |
assert!(FieldKind::Range.measurable()); |
| 112 |
|
| 113 |
|
| 114 |
assert!(FieldKind::Interval.measurable()); |
| 115 |
|
| 116 |
|
| 117 |
for kind in [ |
| 118 |
FieldKind::Text, |
| 119 |
FieldKind::Date, |
| 120 |
FieldKind::DateTime, |
| 121 |
FieldKind::Select, |
| 122 |
FieldKind::Checkbox, |
| 123 |
FieldKind::File, |
| 124 |
] { |
| 125 |
assert!(!kind.measurable(), "{kind:?}"); |
| 126 |
} |
| 127 |
} |
| 128 |
|
| 129 |
#[test] |
| 130 |
fn a_field_carries_no_unit_until_one_is_given() { |
| 131 |
|
| 132 |
let plain = Field::new(FieldKind::Number, "attack", "Attack"); |
| 133 |
assert_eq!(plain.unit, None); |
| 134 |
let measured = Field { |
| 135 |
unit: Some("s"), |
| 136 |
..Field::range("attack", "Attack", "0.001", "5") |
| 137 |
}; |
| 138 |
assert_eq!(measured.unit, Some("s")); |
| 139 |
assert!(measured.kind.measurable()); |
| 140 |
} |
| 141 |
|
| 142 |
#[test] |
| 143 |
fn an_interval_states_both_ends_names() { |
| 144 |
|
| 145 |
|
| 146 |
let suffixed = Field::interval("bpm_min", "bpm_max", "BPM"); |
| 147 |
assert_eq!(suffixed.name, "bpm_min"); |
| 148 |
assert_eq!(suffixed.upper_name, Some("bpm_max")); |
| 149 |
let prefixed = Field::interval("min_price", "max_price", "Price"); |
| 150 |
assert_eq!(prefixed.name, "min_price"); |
| 151 |
assert_eq!(prefixed.upper_name, Some("max_price")); |
| 152 |
assert_eq!(suffixed.kind, FieldKind::Interval); |
| 153 |
} |
| 154 |
|
| 155 |
#[test] |
| 156 |
fn every_other_kind_has_no_upper_end() { |
| 157 |
|
| 158 |
for kind in [FieldKind::Text, FieldKind::Number, FieldKind::Range] { |
| 159 |
assert_eq!(Field::new(kind, "n", "N").upper_name, None, "{kind:?}"); |
| 160 |
} |
| 161 |
assert_eq!(Field::range("t", "T", "0", "1").upper_name, None); |
| 162 |
} |
| 163 |
|
| 164 |
#[test] |
| 165 |
fn an_interval_owes_no_bounds_and_takes_the_axis_facts_once() { |
| 166 |
|
| 167 |
|
| 168 |
let plain = Field::interval("bpm_min", "bpm_max", "BPM"); |
| 169 |
assert!(!plain.bounded()); |
| 170 |
let axis = Field { |
| 171 |
min: Some("0"), |
| 172 |
max: Some("300"), |
| 173 |
step: Some("1"), |
| 174 |
unit: Some("BPM"), |
| 175 |
..Field::interval("bpm_min", "bpm_max", "BPM") |
| 176 |
}; |
| 177 |
assert!(axis.bounded()); |
| 178 |
assert_eq!(axis.unit, Some("BPM")); |
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
assert!(axis.error.is_none()); |
| 183 |
} |
| 184 |
|
| 185 |
#[test] |
| 186 |
fn only_a_subtree_prunes_and_only_the_listing_modes_offer_values() { |
| 187 |
|
| 188 |
|
| 189 |
assert!(Selecting::Subtree.prunes()); |
| 190 |
for mode in [ |
| 191 |
Selecting::OneOf, |
| 192 |
Selecting::AnyOf, |
| 193 |
Selecting::Range, |
| 194 |
Selecting::Text, |
| 195 |
] { |
| 196 |
assert!(!mode.prunes(), "{mode:?}"); |
| 197 |
} |
| 198 |
|
| 199 |
assert!(!Selecting::Text.offers_values()); |
| 200 |
assert!(!Selecting::Range.offers_values()); |
| 201 |
assert!(Selecting::OneOf.offers_values()); |
| 202 |
assert!(Selecting::AnyOf.accumulates()); |
| 203 |
assert!(!Selecting::OneOf.accumulates()); |
| 204 |
} |
| 205 |
|
| 206 |
#[test] |
| 207 |
fn an_inherited_value_is_in_force_without_having_been_picked() { |
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
assert!(Standing::Inherited.in_force()); |
| 212 |
assert!(!Standing::Inherited.is_picked()); |
| 213 |
assert!(Standing::Taken.in_force()); |
| 214 |
assert!(Standing::Taken.is_picked()); |
| 215 |
|
| 216 |
assert!(Standing::Pruned.is_picked()); |
| 217 |
assert!(!Standing::Pruned.in_force()); |
| 218 |
assert!(!Standing::Open.is_picked()); |
| 219 |
assert!(!Standing::Open.in_force()); |
| 220 |
|
| 221 |
assert_ne!(Standing::Pruned.intent(), "content-muted"); |
| 222 |
} |
| 223 |
|
| 224 |
#[test] |
| 225 |
fn a_facet_is_engaged_by_a_decision_and_not_by_an_inherited_value() { |
| 226 |
let inherited = [ |
| 227 |
FacetValue::of("music") |
| 228 |
.standing(Standing::Taken) |
| 229 |
.at(Nesting::at(0), true), |
| 230 |
FacetValue::new("music/synths", "synths") |
| 231 |
.standing(Standing::Inherited) |
| 232 |
.at(Nesting::at(1), false), |
| 233 |
]; |
| 234 |
let facet = Facet::new("Tag", Selecting::Subtree, &inherited); |
| 235 |
assert!(facet.engaged()); |
| 236 |
|
| 237 |
assert_eq!(facet.reach(), 1); |
| 238 |
|
| 239 |
let untouched = [ |
| 240 |
FacetValue::of("music").at(Nesting::at(0), true), |
| 241 |
FacetValue::new("music/synths", "synths") |
| 242 |
.standing(Standing::Inherited) |
| 243 |
.at(Nesting::at(1), false), |
| 244 |
]; |
| 245 |
|
| 246 |
|
| 247 |
assert!(!Facet::new("Tag", Selecting::Subtree, &untouched).engaged()); |
| 248 |
|
| 249 |
|
| 250 |
|
| 251 |
let typed = Facet::new("Search", Selecting::Text, &[]); |
| 252 |
assert!(!typed.engaged()); |
| 253 |
assert_eq!(typed.reach(), 0); |
| 254 |
} |
| 255 |
|
| 256 |
#[test] |
| 257 |
fn a_count_is_absent_rather_than_zero_when_it_was_not_measured() { |
| 258 |
|
| 259 |
|
| 260 |
assert_eq!(FacetValue::of("Ambient").count, None); |
| 261 |
assert_eq!(FacetValue::of("Ambient").counted(0).count, Some(0)); |
| 262 |
} |
| 263 |
|
| 264 |
#[test] |
| 265 |
fn one_kind_takes_files_and_the_two_file_members_are_its_alone() { |
| 266 |
assert!(FieldKind::File.takes_files()); |
| 267 |
for kind in [ |
| 268 |
FieldKind::Text, |
| 269 |
FieldKind::Textarea, |
| 270 |
FieldKind::Rich, |
| 271 |
FieldKind::Select, |
| 272 |
FieldKind::Checkbox, |
| 273 |
FieldKind::Hidden, |
| 274 |
] { |
| 275 |
assert!(!kind.takes_files()); |
| 276 |
} |
| 277 |
|
| 278 |
|
| 279 |
let plain = Field::new(FieldKind::File, "cover", "Cover"); |
| 280 |
assert!(plain.accept.is_empty()); |
| 281 |
assert!(!plain.multiple); |
| 282 |
} |
| 283 |
|
| 284 |
#[test] |
| 285 |
fn an_accept_list_says_which_disclosure_and_a_suffix_says_none() { |
| 286 |
|
| 287 |
|
| 288 |
assert_eq!( |
| 289 |
Accepted::Family(Family::Image).family(), |
| 290 |
Some(Family::Image) |
| 291 |
); |
| 292 |
assert_eq!(Accepted::Type("image/jpeg").family(), Some(Family::Image)); |
| 293 |
assert_eq!(Accepted::Type("audio/flac").family(), Some(Family::Audio)); |
| 294 |
assert_eq!( |
| 295 |
Accepted::Type("video/quicktime").family(), |
| 296 |
Some(Family::Video) |
| 297 |
); |
| 298 |
|
| 299 |
|
| 300 |
|
| 301 |
assert_eq!(Accepted::Type("text/csv").family(), None); |
| 302 |
assert_eq!(Accepted::Suffix(".mp3").family(), None); |
| 303 |
assert_eq!(Accepted::Suffix(".tar.gz").family(), None); |
| 304 |
|
| 305 |
|
| 306 |
assert_eq!(Accepted::Type("IMAGE/PNG").family(), Some(Family::Image)); |
| 307 |
} |
| 308 |
|
| 309 |
#[test] |
| 310 |
fn every_accepted_entry_has_one_spelling_a_host_can_write() { |
| 311 |
assert_eq!(Accepted::Family(Family::Image).as_str(), "image/*"); |
| 312 |
assert_eq!(Accepted::Family(Family::Audio).as_str(), "audio/*"); |
| 313 |
assert_eq!(Accepted::Family(Family::Video).as_str(), "video/*"); |
| 314 |
assert_eq!(Accepted::Type("text/csv").as_str(), "text/csv"); |
| 315 |
assert_eq!(Accepted::Suffix(".tar.gz").as_str(), ".tar.gz"); |
| 316 |
} |
| 317 |
|
| 318 |
#[test] |
| 319 |
fn a_list_accepting_two_families_still_has_a_disclosure_to_offer() { |
| 320 |
|
| 321 |
|
| 322 |
|
| 323 |
const MEDIA: &[Accepted<'_>] = &[ |
| 324 |
Accepted::Family(Family::Image), |
| 325 |
Accepted::Family(Family::Video), |
| 326 |
]; |
| 327 |
assert!(Field::upload("media", "Media", MEDIA).accepts_media()); |
| 328 |
|
| 329 |
|
| 330 |
const BUILDS: &[Accepted<'_>] = &[Accepted::Suffix(".zip"), Accepted::Suffix(".dmg")]; |
| 331 |
assert!(!Field::upload("build", "Build", BUILDS).accepts_media()); |
| 332 |
|
| 333 |
assert!(!Field::upload("any", "File", &[]).accepts_media()); |
| 334 |
} |
| 335 |
|
| 336 |
#[test] |
| 337 |
fn an_upload_carries_its_list_and_takes_one_file_until_it_says_otherwise() { |
| 338 |
const IMAGES: &[Accepted<'_>] = &[ |
| 339 |
Accepted::Type("image/jpeg"), |
| 340 |
Accepted::Type("image/png"), |
| 341 |
Accepted::Type("image/webp"), |
| 342 |
]; |
| 343 |
let avatar = Field::upload("avatar", "Avatar", IMAGES); |
| 344 |
assert_eq!(avatar.kind, FieldKind::File); |
| 345 |
assert_eq!(avatar.accept, IMAGES); |
| 346 |
assert!(!avatar.multiple); |
| 347 |
let several = Field { |
| 348 |
multiple: true, |
| 349 |
..avatar |
| 350 |
}; |
| 351 |
assert!(several.multiple); |
| 352 |
} |
| 353 |
|
| 354 |
#[test] |
| 355 |
fn the_four_readiness_states_are_one_axis_and_only_one_shows_content() { |
| 356 |
|
| 357 |
|
| 358 |
|
| 359 |
assert!(Readiness::Ready.shows_content()); |
| 360 |
for state in [Readiness::Pending, Readiness::Empty, Readiness::Failed] { |
| 361 |
assert!(!state.shows_content()); |
| 362 |
} |
| 363 |
} |
| 364 |
|
| 365 |
#[test] |
| 366 |
fn a_region_shows_all_of_its_children_unless_it_says_otherwise() { |
| 367 |
|
| 368 |
|
| 369 |
|
| 370 |
assert_eq!(Showing::default(), Showing::All); |
| 371 |
assert!(!Showing::All.selective()); |
| 372 |
} |
| 373 |
|
| 374 |
#[test] |
| 375 |
fn only_a_disclosure_can_show_nothing() { |
| 376 |
|
| 377 |
|
| 378 |
|
| 379 |
|
| 380 |
assert!(Showing::AtMostOne.dismissible()); |
| 381 |
assert!(!Showing::One.dismissible()); |
| 382 |
assert!(!Showing::All.dismissible()); |
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
assert!(Showing::One.selective()); |
| 387 |
assert!(Showing::AtMostOne.selective()); |
| 388 |
} |
| 389 |
|
| 390 |
#[test] |
| 391 |
fn an_empty_region_is_not_a_broken_one() { |
| 392 |
|
| 393 |
|
| 394 |
|
| 395 |
assert_eq!(Readiness::Empty.tone(), Tone::Neutral); |
| 396 |
assert_eq!(Readiness::Failed.tone(), Tone::Danger); |
| 397 |
assert_eq!(Readiness::Pending.tone(), Tone::Neutral); |
| 398 |
} |
| 399 |
|
| 400 |
#[test] |
| 401 |
fn a_column_can_be_sorted_without_being_sortable() { |
| 402 |
|
| 403 |
|
| 404 |
|
| 405 |
let fixed = Column { |
| 406 |
sorted: Some(Sort::Descending), |
| 407 |
..Column::new("Created") |
| 408 |
}; |
| 409 |
|
| 410 |
assert!(!fixed.sortable); |
| 411 |
assert_eq!(fixed.sorted.map(Sort::as_str), Some("descending")); |
| 412 |
|
| 413 |
let offered = Column { |
| 414 |
sortable: true, |
| 415 |
..Column::new("Name") |
| 416 |
}; |
| 417 |
assert_eq!(offered.sorted, None); |
| 418 |
} |
| 419 |
|
| 420 |
#[test] |
| 421 |
fn a_direction_flips_and_says_what_it_is() { |
| 422 |
assert_eq!(Sort::Ascending.reversed(), Sort::Descending); |
| 423 |
assert_eq!(Sort::Descending.reversed().reversed(), Sort::Descending); |
| 424 |
assert_eq!(Sort::Ascending.as_str(), "ascending"); |
| 425 |
} |
| 426 |
|
| 427 |
#[test] |
| 428 |
fn a_direction_carries_its_caret_and_the_two_are_not_the_same_glyph() { |
| 429 |
|
| 430 |
|
| 431 |
assert_eq!(Sort::Ascending.glyph(), "\u{25B2}"); |
| 432 |
assert_eq!(Sort::Descending.glyph(), "\u{25BC}"); |
| 433 |
assert_ne!(Sort::Ascending.glyph(), Sort::Descending.glyph()); |
| 434 |
|
| 435 |
|
| 436 |
for d in [Sort::Ascending, Sort::Descending] { |
| 437 |
assert_eq!(d.glyph().trim(), d.glyph()); |
| 438 |
} |
| 439 |
} |
| 440 |
|
| 441 |
#[test] |
| 442 |
fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() { |
| 443 |
|
| 444 |
|
| 445 |
|
| 446 |
let streak = Figure::new("0", "Current Streak").tone(Tone::Warning); |
| 447 |
assert_eq!(streak.tone, Tone::Warning); |
| 448 |
assert_eq!(Figure::new("17", "Total").tone, Tone::Neutral); |
| 449 |
} |
| 450 |
|
| 451 |
#[test] |
| 452 |
fn a_figures_change_is_the_toned_part_and_is_absent_by_default() { |
| 453 |
|
| 454 |
|
| 455 |
|
| 456 |
let views = Figure::new("1,204", "Views") |
| 457 |
.change("+12.5%") |
| 458 |
.tone(Tone::Success); |
| 459 |
assert_eq!(views.change, Some("+12.5%")); |
| 460 |
assert_eq!(views.tone, Tone::Success); |
| 461 |
|
| 462 |
|
| 463 |
|
| 464 |
assert_eq!(Figure::new("3.1%", "Conversion").change, None); |
| 465 |
} |
| 466 |
|
| 467 |
#[test] |
| 468 |
fn a_figures_value_is_text_because_only_the_app_knows_what_it_is() { |
| 469 |
|
| 470 |
|
| 471 |
|
| 472 |
for value in ["84%", "12/30", "3d"] { |
| 473 |
assert_eq!(Figure::new(value, "Rate").value, value); |
| 474 |
} |
| 475 |
} |
| 476 |
|
| 477 |
#[test] |
| 478 |
fn a_proportion_is_a_row_part_and_takes_no_intent_of_its_own() { |
| 479 |
|
| 480 |
|
| 481 |
assert_eq!(RowPart::Proportion.intent(), RowPart::Tokens.intent()); |
| 482 |
} |
| 483 |
|
| 484 |
#[test] |
| 485 |
fn a_file_field_is_drawn_and_offers_no_options() { |
| 486 |
|
| 487 |
|
| 488 |
assert!(FieldKind::File.visible()); |
| 489 |
assert!(!FieldKind::File.offers_options()); |
| 490 |
assert!(!FieldKind::File.confidential()); |
| 491 |
} |
| 492 |
|
| 493 |
#[test] |
| 494 |
fn a_constraint_is_a_fact_about_the_question_and_not_a_verdict() { |
| 495 |
|
| 496 |
|
| 497 |
|
| 498 |
let field = Field { |
| 499 |
max_length: Some(100), |
| 500 |
min: Some("1"), |
| 501 |
max: Some("240"), |
| 502 |
required: true, |
| 503 |
..Field::new(FieldKind::Number, "minutes", "Minutes") |
| 504 |
}; |
| 505 |
assert!(!field.invalid()); |
| 506 |
|
| 507 |
|
| 508 |
|
| 509 |
let when = Field { |
| 510 |
min: Some("2026-08-09T14:30"), |
| 511 |
..Field::new(FieldKind::Text, "starts", "Starts") |
| 512 |
}; |
| 513 |
assert_eq!(when.min, Some("2026-08-09T14:30")); |
| 514 |
} |
| 515 |
|
| 516 |
#[test] |
| 517 |
fn a_meter_keeps_the_over_run_the_percentage_throws_away() { |
| 518 |
|
| 519 |
|
| 520 |
|
| 521 |
let over = Meter::new(45, 30); |
| 522 |
assert_eq!(over.percent(), 100); |
| 523 |
assert!(over.overflowing()); |
| 524 |
|
| 525 |
let exact = Meter::new(30, 30); |
| 526 |
assert_eq!(exact.percent(), over.percent()); |
| 527 |
assert!(!exact.overflowing()); |
| 528 |
} |
| 529 |
|
| 530 |
#[test] |
| 531 |
fn an_empty_set_does_not_divide_by_zero() { |
| 532 |
|
| 533 |
|
| 534 |
let none = Meter::new(0, 0); |
| 535 |
assert_eq!(none.percent(), 0); |
| 536 |
assert!(none.is_empty()); |
| 537 |
assert!(!none.overflowing()); |
| 538 |
} |
| 539 |
|
| 540 |
#[test] |
| 541 |
fn the_ratio_survives_where_a_percentage_would_not() { |
| 542 |
|
| 543 |
|
| 544 |
let m = Meter::new(3, 7).label("subtasks"); |
| 545 |
assert_eq!(m.percent(), 42); |
| 546 |
assert_eq!((m.done, m.total), (3, 7)); |
| 547 |
assert_eq!(m.label, Some("subtasks")); |
| 548 |
} |
| 549 |
|
| 550 |
#[test] |
| 551 |
fn tone_is_carried_because_no_renderer_can_derive_it() { |
| 552 |
|
| 553 |
|
| 554 |
let subtasks = Meter::new(9, 10).tone(Tone::Success); |
| 555 |
let estimate = Meter::new(9, 10).tone(Tone::Danger); |
| 556 |
assert_eq!(subtasks.percent(), estimate.percent()); |
| 557 |
assert_ne!(subtasks.tone, estimate.tone); |
| 558 |
|
| 559 |
|
| 560 |
assert_eq!(Meter::new(9, 10).tone, Tone::Neutral); |
| 561 |
} |
| 562 |
|
| 563 |
#[test] |
| 564 |
fn an_act_is_reachable_until_it_is_disabled() { |
| 565 |
|
| 566 |
|
| 567 |
|
| 568 |
|
| 569 |
assert!(!Act::new("Save").disabled()); |
| 570 |
assert!(Act::new("Save").state(State::Disabled).disabled()); |
| 571 |
} |
| 572 |
|
| 573 |
#[test] |
| 574 |
fn an_act_carries_its_key_because_a_terminal_has_nothing_else() { |
| 575 |
|
| 576 |
|
| 577 |
assert_eq!(Act::new("Delete").key, None); |
| 578 |
let quit = Act::new("Quit").key("q").tone(Tone::Danger); |
| 579 |
assert_eq!(quit.key, Some("q")); |
| 580 |
assert_eq!(quit.tone, Tone::Danger); |
| 581 |
} |
| 582 |
|
| 583 |
#[test] |
| 584 |
fn a_meter_does_not_overflow_on_large_counts() { |
| 585 |
|
| 586 |
|
| 587 |
|
| 588 |
let big = Meter::new(u32::MAX, u32::MAX); |
| 589 |
assert_eq!(big.percent(), 100); |
| 590 |
assert!(!big.overflowing()); |
| 591 |
} |
| 592 |
|
| 593 |
#[test] |
| 594 |
fn inset_is_raised_with_the_light_moved() { |
| 595 |
let (rl, rd) = Bevel::Raised.edges(); |
| 596 |
let (il, id) = Bevel::Inset.edges(); |
| 597 |
assert_eq!((rl, rd), (Edge::Light, Edge::Dark)); |
| 598 |
assert_eq!((il, id), (rd, rl)); |
| 599 |
} |
| 600 |
|
| 601 |
#[test] |
| 602 |
fn pressing_twice_is_a_no_op() { |
| 603 |
for b in [Bevel::Raised, Bevel::Inset] { |
| 604 |
assert_eq!(b.pressed().pressed(), b); |
| 605 |
} |
| 606 |
} |
| 607 |
|
| 608 |
#[test] |
| 609 |
fn a_raised_region_is_never_filled_with_a_recessed_surface() { |
| 610 |
|
| 611 |
assert_eq!(Depth::Raised.fill(), Some(Fill::Raised)); |
| 612 |
assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised)); |
| 613 |
assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset)); |
| 614 |
assert_ne!(Depth::Well.fill(), Depth::Raised.fill()); |
| 615 |
} |
| 616 |
|
| 617 |
#[test] |
| 618 |
fn state_is_orthogonal_to_depth() { |
| 619 |
|
| 620 |
|
| 621 |
|
| 622 |
assert_eq!(Depth::Raised.fill(), Some(Fill::Raised)); |
| 623 |
assert_eq!(Depth::Well.fill(), Some(Fill::Well)); |
| 624 |
assert!(State::Disabled.suppresses_interaction()); |
| 625 |
} |
| 626 |
|
| 627 |
#[test] |
| 628 |
fn only_disabled_stops_answering() { |
| 629 |
|
| 630 |
|
| 631 |
|
| 632 |
assert!(State::Disabled.suppresses_interaction()); |
| 633 |
} |
| 634 |
|
| 635 |
#[test] |
| 636 |
fn disabled_resolves_against_an_intent_makeover_already_derives() { |
| 637 |
|
| 638 |
assert_eq!(State::Disabled.token(), "content-muted"); |
| 639 |
} |
| 640 |
|
| 641 |
#[test] |
| 642 |
fn flat_has_neither_edge_nor_fill() { |
| 643 |
assert_eq!(Depth::Flat.bevel(), None); |
| 644 |
assert_eq!(Depth::Flat.fill(), None); |
| 645 |
} |
| 646 |
|
| 647 |
#[test] |
| 648 |
fn sunken_is_recessed_by_colour_with_no_edge() { |
| 649 |
|
| 650 |
|
| 651 |
|
| 652 |
assert_eq!(Depth::Sunken.fill(), Some(Fill::Sunken)); |
| 653 |
assert_eq!(Depth::Sunken.bevel(), None); |
| 654 |
} |
| 655 |
|
| 656 |
#[test] |
| 657 |
fn sunken_and_flat_are_different_claims() { |
| 658 |
|
| 659 |
|
| 660 |
assert_eq!(Depth::Flat.bevel(), Depth::Sunken.bevel()); |
| 661 |
assert_ne!(Depth::Flat.fill(), Depth::Sunken.fill()); |
| 662 |
} |
| 663 |
|
| 664 |
#[test] |
| 665 |
fn a_sunken_surface_is_not_a_well() { |
| 666 |
|
| 667 |
|
| 668 |
|
| 669 |
assert_ne!(Fill::Sunken, Fill::Well); |
| 670 |
assert_eq!(Fill::Sunken.token(), "surface-sunken"); |
| 671 |
assert_eq!(Fill::Well.token(), "surface-well"); |
| 672 |
} |
| 673 |
|
| 674 |
#[test] |
| 675 |
fn every_selector_describes_both_of_its_states() { |
| 676 |
|
| 677 |
|
| 678 |
for s in [Selector::Tabs, Selector::Segmented, Selector::Toggle] { |
| 679 |
assert_ne!( |
| 680 |
s.chosen(), |
| 681 |
s.unchosen(), |
| 682 |
"{s:?} cannot tell picked from unpicked" |
| 683 |
); |
| 684 |
} |
| 685 |
} |
| 686 |
|
| 687 |
#[test] |
| 688 |
fn only_a_tab_inverts_the_other_way() { |
| 689 |
|
| 690 |
|
| 691 |
|
| 692 |
|
| 693 |
assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken); |
| 694 |
assert_eq!(Selector::Tabs.chosen(), Depth::Raised); |
| 695 |
|
| 696 |
for s in [Selector::Segmented, Selector::Toggle] { |
| 697 |
assert_eq!(s.unchosen(), Depth::Raised); |
| 698 |
assert_eq!(s.chosen(), Depth::Well); |
| 699 |
|
| 700 |
assert_eq!(s.unchosen().pressed(), s.chosen()); |
| 701 |
} |
| 702 |
} |
| 703 |
|
| 704 |
#[test] |
| 705 |
fn pressing_a_card_makes_a_well() { |
| 706 |
assert_eq!(Depth::Raised.pressed(), Depth::Well); |
| 707 |
assert_eq!( |
| 708 |
Depth::Raised.pressed().bevel(), |
| 709 |
Depth::Raised.bevel().map(Bevel::pressed) |
| 710 |
); |
| 711 |
|
| 712 |
assert_eq!(Depth::Flat.pressed(), Depth::Flat); |
| 713 |
assert_eq!(Depth::Well.pressed(), Depth::Well); |
| 714 |
|
| 715 |
assert_eq!(Depth::Overlay.pressed(), Depth::Overlay); |
| 716 |
} |
| 717 |
|
| 718 |
#[test] |
| 719 |
fn an_overlay_is_lifted_rather_than_edged() { |
| 720 |
|
| 721 |
|
| 722 |
|
| 723 |
assert_eq!(Depth::Overlay.fill(), Some(Fill::Overlay)); |
| 724 |
assert_eq!(Depth::Overlay.bevel(), None); |
| 725 |
|
| 726 |
|
| 727 |
|
| 728 |
|
| 729 |
assert_ne!(Depth::Overlay.fill(), Depth::Sunken.fill()); |
| 730 |
assert_ne!(Depth::Overlay.fill(), Depth::Flat.fill()); |
| 731 |
} |
| 732 |
|
| 733 |
#[test] |
| 734 |
fn a_note_is_neither_a_hint_nor_an_error() { |
| 735 |
|
| 736 |
|
| 737 |
|
| 738 |
let format = Field { |
| 739 |
note: Some((Tone::Warning, "Re-encoding drops embedded BWF and iXML")), |
| 740 |
..Field::select("format", "Format", &[]) |
| 741 |
}; |
| 742 |
assert!(!format.invalid(), "a note is not a validation failure"); |
| 743 |
assert!(format.hint.is_none()); |
| 744 |
assert!(format.error.is_none()); |
| 745 |
assert_eq!(format.note.unwrap().0, Tone::Warning); |
| 746 |
|
| 747 |
|
| 748 |
|
| 749 |
assert!(Field::new(FieldKind::Text, "title", "Title").note.is_none()); |
| 750 |
} |
| 751 |
|
| 752 |
#[test] |
| 753 |
fn neutral_is_content_not_muted_content() { |
| 754 |
|
| 755 |
|
| 756 |
|
| 757 |
|
| 758 |
assert_eq!(Tone::Neutral.token(), "content"); |
| 759 |
assert!(!Token::Badge.interactive()); |
| 760 |
assert_eq!(State::Disabled.token(), "content-muted"); |
| 761 |
} |
| 762 |
|
| 763 |
#[test] |
| 764 |
fn intents_name_makeover_tokens_and_nothing_else() { |
| 765 |
assert_eq!(Edge::Light.token(), "bevel-light"); |
| 766 |
assert_eq!(Edge::Dark.token(), "bevel-dark"); |
| 767 |
assert_eq!(Fill::Raised.token(), "surface-raised"); |
| 768 |
assert_eq!(Fill::Well.token(), "surface-well"); |
| 769 |
|
| 770 |
for t in [ |
| 771 |
Edge::Light.token(), |
| 772 |
Edge::Dark.token(), |
| 773 |
Tone::Danger.token(), |
| 774 |
Tone::Neutral.token(), |
| 775 |
State::Disabled.token(), |
| 776 |
] { |
| 777 |
assert!(!t.starts_with('#'), "{t} looks like a value"); |
| 778 |
assert!( |
| 779 |
!t.chars().next().unwrap().is_ascii_digit(), |
| 780 |
"{t} is a value" |
| 781 |
); |
| 782 |
} |
| 783 |
} |
| 784 |
|
| 785 |
#[test] |
| 786 |
fn a_badge_cannot_be_pressed_and_a_chip_latches() { |
| 787 |
|
| 788 |
assert!(!Token::Badge.interactive()); |
| 789 |
assert!(Token::Chip { removable: false }.interactive()); |
| 790 |
assert!(Token::Chip { removable: true }.interactive()); |
| 791 |
|
| 792 |
|
| 793 |
assert_eq!(Token::Badge.depth(false), Depth::Flat); |
| 794 |
assert_eq!(Token::Badge.depth(true), Depth::Flat); |
| 795 |
|
| 796 |
|
| 797 |
let chip = Token::Chip { removable: false }; |
| 798 |
assert_eq!(chip.depth(false), Depth::Raised); |
| 799 |
assert_eq!(chip.depth(true), Depth::Raised.pressed()); |
| 800 |
} |
| 801 |
|
| 802 |
#[test] |
| 803 |
fn a_toast_and_a_banner_differ_in_more_than_placement() { |
| 804 |
assert!(Notice::Toast.transient()); |
| 805 |
assert!(!Notice::Banner.transient()); |
| 806 |
|
| 807 |
assert_eq!(Notice::Toast.fill(), Fill::Overlay); |
| 808 |
assert_eq!(Notice::Banner.fill(), Fill::Raised); |
| 809 |
} |
| 810 |
|
| 811 |
#[test] |
| 812 |
fn emphasis_falls_off_down_the_row() { |
| 813 |
|
| 814 |
|
| 815 |
|
| 816 |
|
| 817 |
assert_eq!(RowPart::Primary.intent(), "content"); |
| 818 |
assert_eq!(RowPart::Secondary.intent(), "content-secondary"); |
| 819 |
assert_eq!(RowPart::Meta.intent(), "content-muted"); |
| 820 |
} |
| 821 |
|
| 822 |
#[test] |
| 823 |
fn a_token_part_carries_no_intent_of_its_own() { |
| 824 |
|
| 825 |
|
| 826 |
|
| 827 |
assert_eq!(RowPart::Tokens.intent(), RowPart::Actions.intent()); |
| 828 |
assert_eq!(RowPart::Tokens.intent(), "content"); |
| 829 |
} |
| 830 |
|
| 831 |
#[test] |
| 832 |
fn the_two_temporal_kinds_are_the_two_that_name_a_moment() { |
| 833 |
|
| 834 |
|
| 835 |
assert!(FieldKind::Date.temporal()); |
| 836 |
assert!(FieldKind::DateTime.temporal()); |
| 837 |
|
| 838 |
for kind in [ |
| 839 |
FieldKind::Text, |
| 840 |
FieldKind::Secret, |
| 841 |
FieldKind::Number, |
| 842 |
FieldKind::Email, |
| 843 |
FieldKind::Url, |
| 844 |
FieldKind::Tel, |
| 845 |
FieldKind::Range, |
| 846 |
FieldKind::Textarea, |
| 847 |
FieldKind::Rich, |
| 848 |
FieldKind::Select, |
| 849 |
FieldKind::Radio, |
| 850 |
FieldKind::Checkbox, |
| 851 |
FieldKind::File, |
| 852 |
FieldKind::Hidden, |
| 853 |
] { |
| 854 |
assert!(!kind.temporal(), "{kind:?}"); |
| 855 |
} |
| 856 |
} |
| 857 |
|
| 858 |
#[test] |
| 859 |
fn a_date_carries_no_time_and_a_datetime_carries_no_zone() { |
| 860 |
|
| 861 |
|
| 862 |
|
| 863 |
assert_eq!(DATE_FORMAT, "%Y-%m-%d"); |
| 864 |
assert!(!DATE_FORMAT.contains("%H"), "a day carries no hour"); |
| 865 |
|
| 866 |
assert_eq!(DATETIME_FORMAT, "%Y-%m-%dT%H:%M"); |
| 867 |
assert!( |
| 868 |
DATETIME_FORMAT.starts_with(DATE_FORMAT), |
| 869 |
"a moment starts with the day it is on" |
| 870 |
); |
| 871 |
|
| 872 |
assert!(!DATETIME_FORMAT.contains("%Z"), "no zone name"); |
| 873 |
assert!(!DATETIME_FORMAT.ends_with('Z'), "not UTC-stamped"); |
| 874 |
assert!(!DATETIME_FORMAT.contains("%S"), "no seconds by default"); |
| 875 |
} |
| 876 |
|
| 877 |
#[test] |
| 878 |
fn a_temporal_kind_takes_a_label_above_it_and_offers_no_options() { |
| 879 |
|
| 880 |
|
| 881 |
|
| 882 |
for kind in [FieldKind::Date, FieldKind::DateTime] { |
| 883 |
assert!(kind.visible(), "{kind:?}"); |
| 884 |
assert!(!kind.confidential(), "{kind:?}"); |
| 885 |
assert!(!kind.labels_itself(), "{kind:?}"); |
| 886 |
assert!(!kind.offers_options(), "{kind:?}"); |
| 887 |
} |
| 888 |
} |
| 889 |
|
| 890 |
#[test] |
| 891 |
fn a_cell_part_names_an_intent_and_only_the_value_is_text() { |
| 892 |
|
| 893 |
|
| 894 |
|
| 895 |
assert_eq!(CellPart::Value.intent(), "content"); |
| 896 |
|
| 897 |
for part in [CellPart::Tokens, CellPart::Actions, CellPart::Link] { |
| 898 |
|
| 899 |
|
| 900 |
|
| 901 |
assert_eq!(part.intent(), CellPart::Value.intent(), "{part:?}"); |
| 902 |
} |
| 903 |
} |
| 904 |
|
| 905 |
#[test] |
| 906 |
fn every_cell_part_answers_with_a_token_and_never_a_value() { |
| 907 |
for part in [ |
| 908 |
CellPart::Value, |
| 909 |
CellPart::Tokens, |
| 910 |
CellPart::Actions, |
| 911 |
CellPart::Link, |
| 912 |
] { |
| 913 |
let intent = part.intent(); |
| 914 |
assert!(!intent.is_empty(), "{part:?} names nothing"); |
| 915 |
assert!(!intent.starts_with('#'), "{part:?} looks like a value"); |
| 916 |
} |
| 917 |
} |
| 918 |
|
| 919 |
#[test] |
| 920 |
fn a_separator_is_what_tells_a_section_from_a_subsection() { |
| 921 |
assert!(Heading::Section.separated()); |
| 922 |
assert!(!Heading::Subsection.separated()); |
| 923 |
assert!(!Heading::Page.separated()); |
| 924 |
} |
| 925 |
|
| 926 |
#[test] |
| 927 |
fn a_chosen_segment_is_held_in_and_a_chosen_tab_comes_forward() { |
| 928 |
assert_eq!(Selector::Segmented.chosen(), Depth::Well); |
| 929 |
assert_eq!(Selector::Toggle.chosen(), Depth::Well); |
| 930 |
|
| 931 |
|
| 932 |
assert_eq!(Selector::Tabs.chosen(), Depth::Raised); |
| 933 |
|
| 934 |
|
| 935 |
|
| 936 |
assert_eq!(Selector::Segmented.chosen(), Depth::Raised.pressed()); |
| 937 |
|
| 938 |
|
| 939 |
|
| 940 |
assert!(Selector::Segmented.abutting()); |
| 941 |
assert!(Selector::Tabs.abutting()); |
| 942 |
assert!(!Selector::Toggle.abutting()); |
| 943 |
} |
| 944 |
|
| 945 |
#[test] |
| 946 |
fn columns_are_peers_and_a_split_is_not() { |
| 947 |
|
| 948 |
|
| 949 |
|
| 950 |
assert_eq!(Region::Columns.depth(), Depth::Flat); |
| 951 |
assert_eq!(Region::Split.depth(), Depth::Flat); |
| 952 |
assert_ne!(Region::Columns, Region::Split); |
| 953 |
} |
| 954 |
|
| 955 |
#[test] |
| 956 |
fn columns_carry_no_count_and_no_share() { |
| 957 |
|
| 958 |
|
| 959 |
|
| 960 |
|
| 961 |
|
| 962 |
|
| 963 |
|
| 964 |
|
| 965 |
let columns: Region<'_> = Region::Columns; |
| 966 |
assert_eq!(columns.name(), None); |
| 967 |
} |
| 968 |
|
| 969 |
#[test] |
| 970 |
fn columns_are_described_and_the_escape_hatch_is_still_one_member() { |
| 971 |
|
| 972 |
|
| 973 |
|
| 974 |
assert!(Region::Columns.described()); |
| 975 |
assert!(!Region::Handover { name: "timeline" }.described()); |
| 976 |
} |
| 977 |
|
| 978 |
#[test] |
| 979 |
fn a_span_never_has_zero_minutes_however_it_is_asked_for() { |
| 980 |
|
| 981 |
|
| 982 |
assert_eq!(Span::new(600, 600).length(), 1); |
| 983 |
assert_eq!(Span::new(600, 300).length(), 1); |
| 984 |
assert_eq!(Span::DAY.length(), 1440); |
| 985 |
} |
| 986 |
|
| 987 |
#[test] |
| 988 |
fn a_span_can_run_past_midnight_without_a_second_date() { |
| 989 |
|
| 990 |
|
| 991 |
let overnight = Span::new(1320, 1560); |
| 992 |
assert_eq!(overnight.length(), 240); |
| 993 |
assert!(overnight.holds(1500)); |
| 994 |
assert!(!overnight.holds(1200)); |
| 995 |
} |
| 996 |
|
| 997 |
#[test] |
| 998 |
fn overlap_is_computed_rather_than_declared() { |
| 999 |
|
| 1000 |
|
| 1001 |
|
| 1002 |
let morning = Placement::new(540, 60); |
| 1003 |
let overlapping = Placement::new(570, 60); |
| 1004 |
let after = Placement::new(600, 60); |
| 1005 |
|
| 1006 |
assert!(morning.overlaps(overlapping)); |
| 1007 |
assert!(overlapping.overlaps(morning), "overlap is symmetric"); |
| 1008 |
|
| 1009 |
|
| 1010 |
assert!(!morning.overlaps(after)); |
| 1011 |
assert!(!after.overlaps(morning)); |
| 1012 |
} |
| 1013 |
|
| 1014 |
#[test] |
| 1015 |
fn a_placement_is_always_drawable() { |
| 1016 |
assert_eq!(Placement::new(540, 0).length(), 1); |
| 1017 |
assert_eq!(Placement::new(540, 30).end(), 570); |
| 1018 |
} |
| 1019 |
|
| 1020 |
#[test] |
| 1021 |
fn a_track_places_the_fraction_every_renderer_would_otherwise_compute() { |
| 1022 |
let day = Track::DAY; |
| 1023 |
assert!((day.fraction(0) - 0.0).abs() < f32::EPSILON); |
| 1024 |
assert!((day.fraction(720) - 0.5).abs() < f32::EPSILON); |
| 1025 |
|
| 1026 |
|
| 1027 |
assert!((day.fraction(2000) - 1.0).abs() < f32::EPSILON); |
| 1028 |
} |
| 1029 |
|
| 1030 |
#[test] |
| 1031 |
fn a_track_counts_its_slots_and_never_divides_by_zero() { |
| 1032 |
assert_eq!(Track::DAY.slots(), 96); |
| 1033 |
assert_eq!(Track::over(Span::new(540, 1020)).slots(), 32); |
| 1034 |
|
| 1035 |
assert_eq!(Track::over(Span::new(0, 50)).slots(), 4); |
| 1036 |
|
| 1037 |
let degenerate = Track { |
| 1038 |
span: Span::DAY, |
| 1039 |
slot: 0, |
| 1040 |
tick: 60, |
| 1041 |
unit: Unit::Minutes, |
| 1042 |
}; |
| 1043 |
assert_eq!(degenerate.slots(), 1); |
| 1044 |
} |
| 1045 |
|
| 1046 |
#[test] |
| 1047 |
fn a_track_carries_facts_and_no_presentation() { |
| 1048 |
|
| 1049 |
|
| 1050 |
|
| 1051 |
|
| 1052 |
let day = Track::DAY; |
| 1053 |
assert_eq!(day.span, Span::DAY); |
| 1054 |
assert_eq!(day.slot, 15); |
| 1055 |
assert_eq!(day.tick, 60); |
| 1056 |
assert_eq!(day.unit, Unit::Minutes); |
| 1057 |
|
| 1058 |
|
| 1059 |
|
| 1060 |
|
| 1061 |
|
| 1062 |
|
| 1063 |
|
| 1064 |
|
| 1065 |
|
| 1066 |
|
| 1067 |
let Track { |
| 1068 |
span: _, |
| 1069 |
slot: _, |
| 1070 |
tick: _, |
| 1071 |
unit: _, |
| 1072 |
} = day; |
| 1073 |
} |
| 1074 |
|
| 1075 |
#[test] |
| 1076 |
fn a_day_strip_is_the_same_arithmetic_under_a_different_unit() { |
| 1077 |
|
| 1078 |
|
| 1079 |
|
| 1080 |
|
| 1081 |
let march = Track::days(Span::new(0, 31)); |
| 1082 |
assert_eq!(march.slots(), 31); |
| 1083 |
assert_eq!(march.unit, Unit::Days); |
| 1084 |
|
| 1085 |
let leave = Placement::new(2, 15); |
| 1086 |
assert!((march.fraction(leave.at()) - 2.0 / 31.0).abs() < 0.0001); |
| 1087 |
assert!((march.fraction(leave.end()) - 17.0 / 31.0).abs() < 0.0001); |
| 1088 |
} |
| 1089 |
|
| 1090 |
#[test] |
| 1091 |
fn a_pane_is_looked_into_and_a_band_is_not() { |
| 1092 |
assert_eq!(Region::Pane.depth(), Depth::Well); |
| 1093 |
assert_eq!(Region::Modal.depth(), Depth::Raised); |
| 1094 |
for r in [ |
| 1095 |
Region::Band, |
| 1096 |
Region::Sidebar, |
| 1097 |
Region::Group, |
| 1098 |
Region::Split, |
| 1099 |
Region::TabGroup, |
| 1100 |
] { |
| 1101 |
assert_eq!(r.depth(), Depth::Flat, "{r:?} should carry no edge"); |
| 1102 |
} |
| 1103 |
} |
| 1104 |
|
| 1105 |
#[test] |
| 1106 |
fn exactly_one_region_is_opaque() { |
| 1107 |
|
| 1108 |
|
| 1109 |
|
| 1110 |
for r in [ |
| 1111 |
Region::Band, |
| 1112 |
Region::Sidebar, |
| 1113 |
Region::Pane, |
| 1114 |
Region::Group, |
| 1115 |
Region::Split, |
| 1116 |
Region::TabGroup, |
| 1117 |
Region::Modal, |
| 1118 |
|
| 1119 |
|
| 1120 |
|
| 1121 |
|
| 1122 |
Region::Widget { name: "carousel" }, |
| 1123 |
] { |
| 1124 |
assert!(r.described(), "{r:?} should be describable"); |
| 1125 |
} |
| 1126 |
assert!(!Region::Handover { name: "day-plan" }.described()); |
| 1127 |
} |
| 1128 |
|
| 1129 |
#[test] |
| 1130 |
fn a_region_nobody_converted_yet_is_not_a_region_the_app_gave_up_on() { |
| 1131 |
|
| 1132 |
|
| 1133 |
|
| 1134 |
assert!(Region::Handover { name: "day-plan" }.owed()); |
| 1135 |
assert!( |
| 1136 |
!Region::Ceded { |
| 1137 |
name: "revenue-chart" |
| 1138 |
} |
| 1139 |
.owed() |
| 1140 |
); |
| 1141 |
|
| 1142 |
|
| 1143 |
|
| 1144 |
assert!(!Region::Pane.owed()); |
| 1145 |
assert!(!Region::Widget { name: "carousel" }.owed()); |
| 1146 |
|
| 1147 |
|
| 1148 |
assert!(!Region::Ceded { name: "waveform" }.described()); |
| 1149 |
assert_eq!(Region::Ceded { name: "waveform" }.name(), Some("waveform")); |
| 1150 |
assert_eq!(Region::Ceded { name: "waveform" }.depth(), Depth::Flat); |
| 1151 |
} |
| 1152 |
|
| 1153 |
#[test] |
| 1154 |
fn a_group_contains_a_section_without_claiming_to_be_a_pane() { |
| 1155 |
|
| 1156 |
|
| 1157 |
|
| 1158 |
assert_eq!(Region::Pane.depth(), Depth::Well); |
| 1159 |
assert_eq!(Region::Group.depth(), Depth::Flat); |
| 1160 |
assert_ne!(Region::Group, Region::Pane); |
| 1161 |
|
| 1162 |
|
| 1163 |
|
| 1164 |
|
| 1165 |
assert!(Region::Group.described()); |
| 1166 |
assert_eq!(Region::Group.name(), None); |
| 1167 |
} |
| 1168 |
|
| 1169 |
#[test] |
| 1170 |
fn a_section_heading_names_a_block_that_now_exists() { |
| 1171 |
|
| 1172 |
|
| 1173 |
|
| 1174 |
|
| 1175 |
assert!(Heading::Section.separated()); |
| 1176 |
assert_eq!(Region::Group.depth(), Depth::Flat); |
| 1177 |
} |
| 1178 |
|
| 1179 |
#[test] |
| 1180 |
fn a_widget_inherits_its_depth_the_way_a_bespoke_does() { |
| 1181 |
|
| 1182 |
|
| 1183 |
|
| 1184 |
assert_eq!(Region::Widget { name: "carousel" }.depth(), Depth::Flat); |
| 1185 |
assert_eq!(Region::Widget { name: "pager" }.depth(), Depth::Flat); |
| 1186 |
} |
| 1187 |
|
| 1188 |
#[test] |
| 1189 |
fn a_name_is_readable_without_asking_which_member_carried_it() { |
| 1190 |
|
| 1191 |
|
| 1192 |
assert_eq!(Region::Widget { name: "carousel" }.name(), Some("carousel")); |
| 1193 |
assert_eq!( |
| 1194 |
Region::Handover { name: "day-plan" }.name(), |
| 1195 |
Some("day-plan") |
| 1196 |
); |
| 1197 |
|
| 1198 |
for r in [ |
| 1199 |
Region::Band, |
| 1200 |
Region::Sidebar, |
| 1201 |
Region::Pane, |
| 1202 |
Region::Group, |
| 1203 |
Region::Split, |
| 1204 |
Region::TabGroup, |
| 1205 |
Region::Modal, |
| 1206 |
] { |
| 1207 |
assert_eq!(r.name(), None, "{r:?} names nothing an app chose"); |
| 1208 |
} |
| 1209 |
} |
| 1210 |
|
| 1211 |
#[test] |
| 1212 |
fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() { |
| 1213 |
|
| 1214 |
|
| 1215 |
assert_eq!(Region::Handover { name: "day-plan" }.depth(), Depth::Flat); |
| 1216 |
assert_eq!(Region::Handover { name: "kanban" }.depth(), Depth::Flat); |
| 1217 |
} |
| 1218 |
|
| 1219 |
#[test] |
| 1220 |
fn a_screen_with_a_bespoke_region_is_still_a_whole_screen() { |
| 1221 |
|
| 1222 |
|
| 1223 |
|
| 1224 |
let day_plan = [ |
| 1225 |
Region::Band, |
| 1226 |
Region::Handover { name: "day-plan" }, |
| 1227 |
Region::Sidebar, |
| 1228 |
]; |
| 1229 |
assert_eq!(day_plan.iter().filter(|r| r.described()).count(), 2); |
| 1230 |
assert_eq!(day_plan.iter().filter(|r| !r.described()).count(), 1); |
| 1231 |
} |
| 1232 |
|
| 1233 |
#[test] |
| 1234 |
fn a_secret_field_is_marked_as_one_and_a_hidden_field_is_not_drawn() { |
| 1235 |
let secret = Field::new(FieldKind::Secret, "password", "Password"); |
| 1236 |
assert!(secret.kind.confidential()); |
| 1237 |
assert!(secret.kind.visible()); |
| 1238 |
|
| 1239 |
assert!(!FieldKind::Hidden.visible()); |
| 1240 |
|
| 1241 |
for k in [ |
| 1242 |
FieldKind::Text, |
| 1243 |
FieldKind::Number, |
| 1244 |
FieldKind::Textarea, |
| 1245 |
FieldKind::Rich, |
| 1246 |
FieldKind::Select, |
| 1247 |
FieldKind::Checkbox, |
| 1248 |
FieldKind::Hidden, |
| 1249 |
] { |
| 1250 |
assert!(!k.confidential(), "{k:?} should not be confidential"); |
| 1251 |
} |
| 1252 |
|
| 1253 |
|
| 1254 |
assert!(FieldKind::Checkbox.labels_itself()); |
| 1255 |
assert!(!FieldKind::Text.labels_itself()); |
| 1256 |
} |
| 1257 |
|
| 1258 |
#[test] |
| 1259 |
fn a_plain_field_offers_nothing_and_a_select_offers_its_options() { |
| 1260 |
let text = Field::new(FieldKind::Text, "title", "Title"); |
| 1261 |
assert!(text.options.is_empty()); |
| 1262 |
assert_eq!(text.placeholder, None); |
| 1263 |
|
| 1264 |
let sizes = [Choice::plain("small"), Choice::plain("large")]; |
| 1265 |
let select = Field::select("size", "Size", &sizes); |
| 1266 |
assert_eq!(select.kind, FieldKind::Select); |
| 1267 |
assert_eq!(select.options.len(), 2); |
| 1268 |
} |
| 1269 |
|
| 1270 |
#[test] |
| 1271 |
fn a_choice_says_what_submits_and_what_is_read_apart() { |
| 1272 |
|
| 1273 |
|
| 1274 |
let plain = Choice::plain("7"); |
| 1275 |
assert_eq!((plain.value, plain.label), ("7", "7")); |
| 1276 |
|
| 1277 |
let spelled = Choice::new("7", "One week"); |
| 1278 |
assert_ne!(spelled.value, spelled.label); |
| 1279 |
assert!( |
| 1280 |
spelled.available(), |
| 1281 |
"an option is pickable until it says not" |
| 1282 |
); |
| 1283 |
} |
| 1284 |
|
| 1285 |
#[test] |
| 1286 |
fn a_candidate_carries_the_line_that_tells_it_from_its_neighbours() { |
| 1287 |
|
| 1288 |
|
| 1289 |
|
| 1290 |
|
| 1291 |
let audio = Candidate::new("audio/format", "Format").detailed("Audio"); |
| 1292 |
let writing = Candidate::new("writing/format", "Format").detailed("Writing"); |
| 1293 |
|
| 1294 |
assert_eq!(audio.label, writing.label); |
| 1295 |
assert_ne!(audio.detail, writing.detail); |
| 1296 |
assert_ne!( |
| 1297 |
audio, writing, |
| 1298 |
"two rows a user cannot tell apart are two rows the type can" |
| 1299 |
); |
| 1300 |
} |
| 1301 |
|
| 1302 |
#[test] |
| 1303 |
fn a_candidate_is_read_differently_from_an_option_and_written_the_same() { |
| 1304 |
|
| 1305 |
|
| 1306 |
|
| 1307 |
|
| 1308 |
let candidate = Candidate::plain("rust"); |
| 1309 |
assert_eq!((candidate.value, candidate.label), ("rust", "rust")); |
| 1310 |
assert_eq!( |
| 1311 |
candidate.detail, None, |
| 1312 |
"one line unless the route says otherwise" |
| 1313 |
); |
| 1314 |
|
| 1315 |
let option = Choice::plain("rust"); |
| 1316 |
assert_eq!( |
| 1317 |
(candidate.value, candidate.label), |
| 1318 |
(option.value, option.label) |
| 1319 |
); |
| 1320 |
} |
| 1321 |
|
| 1322 |
#[test] |
| 1323 |
fn a_radio_asks_the_same_question_as_a_select_and_is_not_the_same_kind() { |
| 1324 |
|
| 1325 |
|
| 1326 |
|
| 1327 |
|
| 1328 |
|
| 1329 |
let styles = [ |
| 1330 |
Choice::new("copy", "Copy samples in"), |
| 1331 |
Choice::new("reference", "Reference in place"), |
| 1332 |
]; |
| 1333 |
let radio = Field::radio("storage", "Storage style", &styles); |
| 1334 |
let select = Field::select("storage", "Storage style", &styles); |
| 1335 |
|
| 1336 |
assert_eq!(radio.kind, FieldKind::Radio); |
| 1337 |
assert_ne!(radio.kind, select.kind); |
| 1338 |
assert_eq!(radio.options, select.options); |
| 1339 |
assert_eq!( |
| 1340 |
Field { |
| 1341 |
kind: select.kind, |
| 1342 |
..radio |
| 1343 |
}, |
| 1344 |
select |
| 1345 |
); |
| 1346 |
} |
| 1347 |
|
| 1348 |
#[test] |
| 1349 |
fn an_unavailable_option_cannot_be_silent_about_it() { |
| 1350 |
|
| 1351 |
|
| 1352 |
|
| 1353 |
let multi = |
| 1354 |
Choice::new("multi", "Multi-sample").unless("Drop a second sample onto the keyboard."); |
| 1355 |
assert!(!multi.available()); |
| 1356 |
assert_eq!( |
| 1357 |
multi.unavailable, |
| 1358 |
Some("Drop a second sample onto the keyboard.") |
| 1359 |
); |
| 1360 |
|
| 1361 |
|
| 1362 |
|
| 1363 |
assert_eq!(multi.value, "multi"); |
| 1364 |
assert_eq!(multi.label, "Multi-sample"); |
| 1365 |
} |
| 1366 |
|
| 1367 |
#[test] |
| 1368 |
fn an_option_can_say_what_picking_it_means_and_why_it_cannot_be_picked() { |
| 1369 |
|
| 1370 |
|
| 1371 |
|
| 1372 |
|
| 1373 |
let tier = Choice::new("24", "Small Files") |
| 1374 |
.detailing("$24/mo. 2GB/file, 100GB total. Fits audio, plugins, binaries.") |
| 1375 |
.unless("Sold out while the founder window is open."); |
| 1376 |
|
| 1377 |
assert_eq!( |
| 1378 |
tier.detail, |
| 1379 |
Some("$24/mo. 2GB/file, 100GB total. Fits audio, plugins, binaries.") |
| 1380 |
); |
| 1381 |
assert_eq!( |
| 1382 |
tier.unavailable, |
| 1383 |
Some("Sold out while the founder window is open.") |
| 1384 |
); |
| 1385 |
assert!(!tier.available()); |
| 1386 |
|
| 1387 |
|
| 1388 |
|
| 1389 |
|
| 1390 |
let plain = Choice::new("free", "Free").detailing("No charge. Available to everyone."); |
| 1391 |
assert!(plain.available()); |
| 1392 |
assert_eq!(plain.detail, Some("No charge. Available to everyone.")); |
| 1393 |
assert_eq!(Choice::new("free", "Free").detail, None); |
| 1394 |
} |
| 1395 |
|
| 1396 |
#[test] |
| 1397 |
fn a_range_carries_both_ends_and_a_validated_number_need_not() { |
| 1398 |
|
| 1399 |
|
| 1400 |
|
| 1401 |
let threshold = Field::range("review", "Review above", "0", "1"); |
| 1402 |
assert_eq!(threshold.kind, FieldKind::Range); |
| 1403 |
assert!(threshold.bounded()); |
| 1404 |
assert_eq!(threshold.min, Some("0")); |
| 1405 |
assert_eq!(threshold.max, Some("1")); |
| 1406 |
|
| 1407 |
assert_eq!(threshold.step, None); |
| 1408 |
|
| 1409 |
|
| 1410 |
|
| 1411 |
let minutes = Field { |
| 1412 |
min: Some("1"), |
| 1413 |
..Field::new(FieldKind::Number, "minutes", "Minutes") |
| 1414 |
}; |
| 1415 |
assert_ne!(minutes.kind, FieldKind::Range); |
| 1416 |
assert!(!minutes.bounded(), "one end is a rule, not an extent"); |
| 1417 |
} |
| 1418 |
|
| 1419 |
#[test] |
| 1420 |
fn a_range_described_with_one_end_says_so_rather_than_being_refused() { |
| 1421 |
|
| 1422 |
|
| 1423 |
|
| 1424 |
let half = Field { |
| 1425 |
max: Some("1"), |
| 1426 |
..Field::new(FieldKind::Range, "review", "Review above") |
| 1427 |
}; |
| 1428 |
assert!(!half.bounded()); |
| 1429 |
} |
| 1430 |
|
| 1431 |
#[test] |
| 1432 |
fn exactly_the_option_taking_kinds_say_so() { |
| 1433 |
|
| 1434 |
|
| 1435 |
assert!(FieldKind::Select.offers_options()); |
| 1436 |
assert!(FieldKind::Radio.offers_options()); |
| 1437 |
for kind in [ |
| 1438 |
FieldKind::Text, |
| 1439 |
FieldKind::Secret, |
| 1440 |
FieldKind::Number, |
| 1441 |
FieldKind::Email, |
| 1442 |
FieldKind::Url, |
| 1443 |
FieldKind::Tel, |
| 1444 |
FieldKind::Range, |
| 1445 |
FieldKind::Textarea, |
| 1446 |
FieldKind::Rich, |
| 1447 |
FieldKind::Checkbox, |
| 1448 |
FieldKind::Hidden, |
| 1449 |
] { |
| 1450 |
assert!(!kind.offers_options(), "{kind:?} does not offer options"); |
| 1451 |
} |
| 1452 |
} |
| 1453 |
|
| 1454 |
#[test] |
| 1455 |
fn a_radio_group_takes_a_label_even_though_its_options_carry_their_own() { |
| 1456 |
|
| 1457 |
|
| 1458 |
|
| 1459 |
assert!(!FieldKind::Radio.labels_itself()); |
| 1460 |
assert!(FieldKind::Checkbox.labels_itself()); |
| 1461 |
} |
| 1462 |
|
| 1463 |
#[test] |
| 1464 |
fn a_select_with_no_options_is_sayable() { |
| 1465 |
|
| 1466 |
|
| 1467 |
|
| 1468 |
let loading = Field::select("project", "Project", &[]); |
| 1469 |
assert!(loading.options.is_empty()); |
| 1470 |
} |
| 1471 |
|
| 1472 |
#[test] |
| 1473 |
fn the_description_carries_the_question_and_never_the_answer() { |
| 1474 |
|
| 1475 |
|
| 1476 |
|
| 1477 |
let f = Field { |
| 1478 |
placeholder: Some("yyyy-mm-dd"), |
| 1479 |
..Field::new(FieldKind::Text, "due", "Due") |
| 1480 |
}; |
| 1481 |
assert_eq!(f.placeholder, Some("yyyy-mm-dd")); |
| 1482 |
|
| 1483 |
|
| 1484 |
assert_eq!(f.label, "Due"); |
| 1485 |
} |
| 1486 |
|
| 1487 |
#[test] |
| 1488 |
fn a_field_reports_its_own_error_state() { |
| 1489 |
let mut f = Field::new(FieldKind::Text, "title", "Title"); |
| 1490 |
assert!(!f.invalid()); |
| 1491 |
f.error = Some("Required"); |
| 1492 |
assert!(f.invalid()); |
| 1493 |
} |
| 1494 |
|
| 1495 |
#[test] |
| 1496 |
fn columns_drop_by_priority_and_never_by_position() { |
| 1497 |
let cols = [ |
| 1498 |
Column { |
| 1499 |
width: Width::Fill, |
| 1500 |
priority: Priority::Essential, |
| 1501 |
..Column::new("Title") |
| 1502 |
}, |
| 1503 |
Column { |
| 1504 |
width: Width::Fixed, |
| 1505 |
priority: Priority::Secondary, |
| 1506 |
..Column::new("Due") |
| 1507 |
}, |
| 1508 |
Column { |
| 1509 |
width: Width::Fixed, |
| 1510 |
priority: Priority::Optional, |
| 1511 |
..Column::new("Estimate") |
| 1512 |
}, |
| 1513 |
]; |
| 1514 |
|
| 1515 |
|
| 1516 |
assert_eq!( |
| 1517 |
cols.iter() |
| 1518 |
.filter(|c| c.kept_at(Priority::Optional)) |
| 1519 |
.count(), |
| 1520 |
3 |
| 1521 |
); |
| 1522 |
|
| 1523 |
let kept: Vec<_> = cols |
| 1524 |
.iter() |
| 1525 |
.filter(|c| c.kept_at(Priority::Secondary)) |
| 1526 |
.map(|c| c.name) |
| 1527 |
.collect(); |
| 1528 |
assert_eq!(kept, ["Title", "Due"]); |
| 1529 |
|
| 1530 |
let kept: Vec<_> = cols |
| 1531 |
.iter() |
| 1532 |
.filter(|c| c.kept_at(Priority::Essential)) |
| 1533 |
.map(|c| c.name) |
| 1534 |
.collect(); |
| 1535 |
assert_eq!(kept, ["Title"]); |
| 1536 |
} |
| 1537 |
|
| 1538 |
#[test] |
| 1539 |
fn inserting_a_column_does_not_move_what_gets_dropped() { |
| 1540 |
|
| 1541 |
|
| 1542 |
|
| 1543 |
let before = [ |
| 1544 |
Column::new("Title"), |
| 1545 |
Column { |
| 1546 |
width: Width::Fixed, |
| 1547 |
priority: Priority::Optional, |
| 1548 |
..Column::new("Estimate") |
| 1549 |
}, |
| 1550 |
]; |
| 1551 |
let after = [ |
| 1552 |
Column::new("Title"), |
| 1553 |
Column::new("Project"), |
| 1554 |
Column { |
| 1555 |
width: Width::Fixed, |
| 1556 |
priority: Priority::Optional, |
| 1557 |
..Column::new("Estimate") |
| 1558 |
}, |
| 1559 |
]; |
| 1560 |
|
| 1561 |
fn dropped<'a>(cols: &[Column<'a>]) -> Vec<&'a str> { |
| 1562 |
cols.iter() |
| 1563 |
.filter(|c| !c.kept_at(Priority::Secondary)) |
| 1564 |
.map(|c| c.name) |
| 1565 |
.collect() |
| 1566 |
} |
| 1567 |
assert_eq!(dropped(&before), ["Estimate"]); |
| 1568 |
assert_eq!(dropped(&after), ["Estimate"]); |
| 1569 |
} |
| 1570 |
|
| 1571 |
#[test] |
| 1572 |
fn an_arrangement_carries_the_tab_group_as_a_modifier() { |
| 1573 |
|
| 1574 |
|
| 1575 |
let go = Arrangement::list_detail(true); |
| 1576 |
let plain = Arrangement::list_detail(false); |
| 1577 |
assert_ne!(go, plain); |
| 1578 |
assert_ne!(go, Arrangement::sidebar_content()); |
| 1579 |
} |
| 1580 |
|
| 1581 |
#[test] |
| 1582 |
fn a_share_is_a_proportion_and_resolves_the_same_way_everywhere() { |
| 1583 |
|
| 1584 |
|
| 1585 |
|
| 1586 |
assert_eq!(Share::LIST.as_percent(), 40); |
| 1587 |
assert_eq!(Share::LIST.of(100), 40); |
| 1588 |
assert_eq!( |
| 1589 |
Share::SIDEBAR.of(96), |
| 1590 |
24, |
| 1591 |
"quasi-tui's 24 columns, said as a quarter" |
| 1592 |
); |
| 1593 |
} |
| 1594 |
|
| 1595 |
#[test] |
| 1596 |
fn a_region_never_resolves_to_nothing() { |
| 1597 |
|
| 1598 |
|
| 1599 |
|
| 1600 |
assert_eq!(Share::percent(5).of(1), 1); |
| 1601 |
assert_eq!(Share::percent(5).of(0), 1); |
| 1602 |
} |
| 1603 |
|
| 1604 |
#[test] |
| 1605 |
fn a_share_outside_the_range_is_clamped_rather_than_refused() { |
| 1606 |
assert_eq!(Share::percent(0), Share::percent(5)); |
| 1607 |
assert_eq!(Share::percent(200), Share::percent(95)); |
| 1608 |
} |
| 1609 |
|
| 1610 |
#[test] |
| 1611 |
fn the_share_rides_on_the_arrangement_that_knows_which_question_it_is() { |
| 1612 |
|
| 1613 |
|
| 1614 |
|
| 1615 |
assert_eq!(Arrangement::sidebar_content().share(), Some(Share::SIDEBAR)); |
| 1616 |
assert_eq!(Arrangement::list_detail(false).share(), Some(Share::LIST)); |
| 1617 |
|
| 1618 |
assert_eq!(Arrangement::Single.share(), None); |
| 1619 |
assert_eq!( |
| 1620 |
Arrangement::Single.with_share(Share::percent(20)), |
| 1621 |
Arrangement::Single |
| 1622 |
); |
| 1623 |
|
| 1624 |
let narrow = Arrangement::sidebar_content().with_share(Share::percent(20)); |
| 1625 |
assert_eq!(narrow.share(), Some(Share::percent(20))); |
| 1626 |
assert!(matches!(narrow, Arrangement::SidebarContent { .. })); |
| 1627 |
} |
| 1628 |
|
| 1629 |
#[test] |
| 1630 |
fn a_measure_defaults_to_the_one_53_of_69_templates_asked_for() { |
| 1631 |
|
| 1632 |
|
| 1633 |
assert_eq!(Measure::default(), Measure::Wide); |
| 1634 |
assert_eq!(Measure::Reading.as_str(), "reading"); |
| 1635 |
} |
| 1636 |
|
| 1637 |
#[test] |
| 1638 |
fn readiness_names_the_state_and_not_the_shimmer() { |
| 1639 |
|
| 1640 |
|
| 1641 |
assert_ne!(Readiness::Ready, Readiness::Pending); |
| 1642 |
} |
| 1643 |
|
| 1644 |
#[test] |
| 1645 |
fn a_window_with_no_length_still_answers_what_it_can() { |
| 1646 |
|
| 1647 |
|
| 1648 |
|
| 1649 |
let uncounted = Window::new(100, 50); |
| 1650 |
assert_eq!(uncounted.index(), Some(2)); |
| 1651 |
assert_eq!(uncounted.windows(), None); |
| 1652 |
assert!(uncounted.has_before()); |
| 1653 |
|
| 1654 |
|
| 1655 |
assert!(uncounted.has_after()); |
| 1656 |
} |
| 1657 |
|
| 1658 |
#[test] |
| 1659 |
fn a_counted_window_knows_where_it_ends() { |
| 1660 |
let last = Window::new(350, 50).of(400); |
| 1661 |
assert_eq!(last.index(), Some(7)); |
| 1662 |
assert_eq!(last.windows(), Some(8)); |
| 1663 |
assert!(last.has_before()); |
| 1664 |
assert!(!last.has_after()); |
| 1665 |
|
| 1666 |
let first = Window::new(0, 50).of(400); |
| 1667 |
assert!(!first.has_before()); |
| 1668 |
assert!(first.has_after()); |
| 1669 |
} |
| 1670 |
|
| 1671 |
#[test] |
| 1672 |
fn a_window_that_does_not_divide_evenly_rounds_up() { |
| 1673 |
|
| 1674 |
|
| 1675 |
assert_eq!(Window::new(0, 50).of(401).windows(), Some(9)); |
| 1676 |
} |
| 1677 |
|
| 1678 |
#[test] |
| 1679 |
fn a_zero_count_answers_none_rather_than_dividing() { |
| 1680 |
let empty = Window::new(0, 0).of(400); |
| 1681 |
assert_eq!(empty.index(), None); |
| 1682 |
assert_eq!(empty.windows(), None); |
| 1683 |
|
| 1684 |
assert_eq!(Window::new(900, 0).of(400).clamped().from, 399); |
| 1685 |
} |
| 1686 |
|
| 1687 |
#[test] |
| 1688 |
fn a_window_past_the_end_clamps_inside_rather_than_vanishing() { |
| 1689 |
|
| 1690 |
|
| 1691 |
|
| 1692 |
assert_eq!(Window::new(900, 50).of(400).clamped().from, 350); |
| 1693 |
|
| 1694 |
assert_eq!(Window::new(900, 50).clamped().from, 900); |
| 1695 |
} |
| 1696 |
|
| 1697 |
#[test] |
| 1698 |
fn a_carousel_frame_is_a_window_of_one() { |
| 1699 |
|
| 1700 |
|
| 1701 |
let third = Window::frame(2, 5); |
| 1702 |
assert_eq!(third.index(), Some(2)); |
| 1703 |
assert_eq!(third.windows(), Some(5)); |
| 1704 |
assert!(third.has_before()); |
| 1705 |
assert!(third.has_after()); |
| 1706 |
|
| 1707 |
let last = Window::frame(4, 5); |
| 1708 |
assert!(!last.has_after()); |
| 1709 |
} |
| 1710 |
|
| 1711 |
#[test] |
| 1712 |
fn numbered_pages_read_from_one_and_load_more_has_no_page() { |
| 1713 |
|
| 1714 |
|
| 1715 |
let third = Paging::pages(100, 50).of(400); |
| 1716 |
assert_eq!(third.page(), Some(3)); |
| 1717 |
assert_eq!(third.pages_total(), Some(8)); |
| 1718 |
assert_eq!(third.total(), Some(400)); |
| 1719 |
assert!(third.has_previous()); |
| 1720 |
assert!(third.has_more()); |
| 1721 |
|
| 1722 |
|
| 1723 |
|
| 1724 |
let grown = Paging::more(150).of(400); |
| 1725 |
assert_eq!(grown.page(), None); |
| 1726 |
assert_eq!(grown.pages_total(), None); |
| 1727 |
assert_eq!(grown.shown(), 150); |
| 1728 |
assert!(!grown.has_previous()); |
| 1729 |
assert!(grown.has_more()); |
| 1730 |
} |
| 1731 |
|
| 1732 |
#[test] |
| 1733 |
fn an_uncounted_paging_offers_forward_and_admits_no_total() { |
| 1734 |
|
| 1735 |
|
| 1736 |
|
| 1737 |
let feed = Paging::more(50); |
| 1738 |
assert_eq!(feed.total(), None); |
| 1739 |
assert_eq!(feed.pages_total(), None); |
| 1740 |
assert_eq!(feed.remaining(), None); |
| 1741 |
assert!(feed.has_more()); |
| 1742 |
} |
| 1743 |
|
| 1744 |
#[test] |
| 1745 |
fn what_is_left_is_derived_and_never_underflows() { |
| 1746 |
assert_eq!(Paging::more(150).of(400).remaining(), Some(250)); |
| 1747 |
assert_eq!(Paging::pages(350, 50).of(400).remaining(), Some(0)); |
| 1748 |
|
| 1749 |
|
| 1750 |
assert_eq!(Paging::more(500).of(400).remaining(), Some(0)); |
| 1751 |
} |
| 1752 |
|
| 1753 |
#[test] |
| 1754 |
fn a_fallback_is_authored_and_a_group_cannot_omit_it() { |
| 1755 |
|
| 1756 |
|
| 1757 |
|
| 1758 |
let all = [ |
| 1759 |
Fallback::Wrap, |
| 1760 |
Fallback::Stack, |
| 1761 |
Fallback::Shed, |
| 1762 |
Fallback::Menu, |
| 1763 |
]; |
| 1764 |
for (i, a) in all.iter().enumerate() { |
| 1765 |
for b in &all[i + 1..] { |
| 1766 |
assert_ne!(a, b); |
| 1767 |
} |
| 1768 |
} |
| 1769 |
} |
| 1770 |
|
| 1771 |
#[test] |
| 1772 |
fn shedding_stops_at_essential_whatever_the_group_holds() { |
| 1773 |
|
| 1774 |
|
| 1775 |
let members = [ |
| 1776 |
("tabs", Priority::Essential), |
| 1777 |
("search", Priority::Secondary), |
| 1778 |
("count", Priority::Optional), |
| 1779 |
]; |
| 1780 |
let kept: Vec<_> = members |
| 1781 |
.iter() |
| 1782 |
.filter(|(_, p)| *p >= Priority::Essential) |
| 1783 |
.map(|(n, _)| *n) |
| 1784 |
.collect(); |
| 1785 |
assert_eq!(kept, ["tabs"]); |
| 1786 |
} |
| 1787 |
|
| 1788 |
#[test] |
| 1789 |
fn a_role_says_what_a_part_is_worth_when_the_run_does_not_fit() { |
| 1790 |
|
| 1791 |
|
| 1792 |
assert_eq!(RowPart::Primary.priority(), Priority::Essential); |
| 1793 |
|
| 1794 |
|
| 1795 |
assert_eq!(RowPart::Actions.priority(), Priority::Essential); |
| 1796 |
assert_eq!(RowPart::Meta.priority(), Priority::Optional); |
| 1797 |
assert_eq!(RowPart::Proportion.priority(), Priority::Optional); |
| 1798 |
assert_eq!(RowPart::Secondary.priority(), Priority::Secondary); |
| 1799 |
|
| 1800 |
|
| 1801 |
assert_eq!(RowPart::Tokens.priority(), Priority::Secondary); |
| 1802 |
} |
| 1803 |
|
| 1804 |
#[test] |
| 1805 |
fn a_run_is_one_line_unless_the_description_says_two() { |
| 1806 |
|
| 1807 |
|
| 1808 |
assert_eq!(Flow::default(), Flow::Tight); |
| 1809 |
assert_eq!(Flow::Tight.lines(), 1); |
| 1810 |
assert_eq!(Flow::Relaxed.lines(), 2); |
| 1811 |
} |
| 1812 |
|
| 1813 |
#[test] |
| 1814 |
fn an_unknown_flow_reads_as_one_line() { |
| 1815 |
|
| 1816 |
|
| 1817 |
|
| 1818 |
|
| 1819 |
|
| 1820 |
for flow in [Flow::Tight, Flow::Relaxed] { |
| 1821 |
assert!((1..=2).contains(&flow.lines())); |
| 1822 |
} |
| 1823 |
} |
| 1824 |
|
| 1825 |
#[test] |
| 1826 |
fn an_awaiting_mark_is_indeterminate_until_something_is_measured() { |
| 1827 |
|
| 1828 |
|
| 1829 |
assert_eq!(Awaiting::default(), Awaiting::unmeasured()); |
| 1830 |
assert!(!Awaiting::unmeasured().is_determinate()); |
| 1831 |
assert!(Awaiting::of(40 * 1024 * 1024).is_determinate()); |
| 1832 |
assert_eq!(Awaiting::of(7).amount, Some(7)); |
| 1833 |
} |
| 1834 |
|
| 1835 |
|
| 1836 |
|
| 1837 |
|
| 1838 |
|
| 1839 |
|
| 1840 |
|
| 1841 |
|
| 1842 |
const ATTACK: (f64, f64) = (0.001, 5.0); |
| 1843 |
|
| 1844 |
#[test] |
| 1845 |
fn a_curve_is_linear_with_no_step_until_a_field_says_otherwise() { |
| 1846 |
assert_eq!(Curve::default(), Curve::Linear { step: None }); |
| 1847 |
let plain = Field::range("t", "T", "0", "1"); |
| 1848 |
assert_eq!(plain.curve, Curve::Linear { step: None }); |
| 1849 |
assert_eq!(plain.curve.step(), None); |
| 1850 |
} |
| 1851 |
|
| 1852 |
#[test] |
| 1853 |
fn every_curve_carries_its_own_granularity() { |
| 1854 |
assert_eq!(Curve::Linear { step: Some("0.01") }.step(), Some("0.01")); |
| 1855 |
assert_eq!( |
| 1856 |
Curve::Logarithmic { |
| 1857 |
step: Some("0.001") |
| 1858 |
} |
| 1859 |
.step(), |
| 1860 |
Some("0.001") |
| 1861 |
); |
| 1862 |
} |
| 1863 |
|
| 1864 |
#[test] |
| 1865 |
fn both_ends_of_the_track_are_the_bounds_under_either_curve() { |
| 1866 |
|
| 1867 |
|
| 1868 |
|
| 1869 |
let (min, max) = ATTACK; |
| 1870 |
for curve in [ |
| 1871 |
Curve::Linear { step: None }, |
| 1872 |
Curve::Logarithmic { step: None }, |
| 1873 |
] { |
| 1874 |
assert!((curve.value_at(0.0, min, max) - min).abs() < 1e-12); |
| 1875 |
assert!((curve.value_at(1.0, min, max) - max).abs() < 1e-12); |
| 1876 |
} |
| 1877 |
} |
| 1878 |
|
| 1879 |
#[test] |
| 1880 |
fn a_linear_midpoint_is_the_average_and_a_ratio_midpoint_is_the_geometric_mean() { |
| 1881 |
let (min, max) = ATTACK; |
| 1882 |
let linear = Curve::Linear { step: None }.value_at(0.5, min, max); |
| 1883 |
assert!((linear - 2.5005).abs() < 1e-9); |
| 1884 |
|
| 1885 |
|
| 1886 |
|
| 1887 |
|
| 1888 |
let ratio = Curve::Logarithmic { step: None }.value_at(0.5, min, max); |
| 1889 |
assert!((ratio - (min * max).sqrt()).abs() < 1e-12); |
| 1890 |
assert!(ratio < 0.08); |
| 1891 |
} |
| 1892 |
|
| 1893 |
#[test] |
| 1894 |
fn a_position_and_a_value_round_trip_under_either_curve() { |
| 1895 |
let (min, max) = ATTACK; |
| 1896 |
for curve in [ |
| 1897 |
Curve::Linear { step: None }, |
| 1898 |
Curve::Logarithmic { step: None }, |
| 1899 |
] { |
| 1900 |
for position in [0.0, 0.1, 0.25, 0.5, 0.75, 0.99, 1.0] { |
| 1901 |
let back = curve.position_of(curve.value_at(position, min, max), min, max); |
| 1902 |
assert!( |
| 1903 |
(back - position).abs() < 1e-9, |
| 1904 |
"{curve:?} lost {position} (got {back})" |
| 1905 |
); |
| 1906 |
} |
| 1907 |
} |
| 1908 |
} |
| 1909 |
|
| 1910 |
#[test] |
| 1911 |
fn a_ratio_curve_across_zero_is_drawn_linearly_rather_than_refused() { |
| 1912 |
|
| 1913 |
|
| 1914 |
|
| 1915 |
let curve = Curve::Logarithmic { step: None }; |
| 1916 |
assert!(!curve.is_ratio(0.0, 1.0)); |
| 1917 |
assert!((curve.value_at(0.5, 0.0, 1.0) - 0.5).abs() < 1e-12); |
| 1918 |
assert!(curve.value_at(0.5, -96.0, -20.0).is_finite()); |
| 1919 |
assert!(curve.is_ratio(ATTACK.0, ATTACK.1)); |
| 1920 |
} |
| 1921 |
|
| 1922 |
#[test] |
| 1923 |
fn a_track_with_no_extent_has_one_value_on_it() { |
| 1924 |
for curve in [ |
| 1925 |
Curve::Linear { step: None }, |
| 1926 |
Curve::Logarithmic { step: None }, |
| 1927 |
] { |
| 1928 |
assert!((curve.value_at(0.7, 4.0, 4.0) - 4.0).abs() < f64::EPSILON); |
| 1929 |
assert!(curve.position_of(4.0, 4.0, 4.0).abs() < f64::EPSILON); |
| 1930 |
|
| 1931 |
|
| 1932 |
assert!((curve.value_at(0.7, 9.0, 2.0) - 9.0).abs() < f64::EPSILON); |
| 1933 |
} |
| 1934 |
} |
| 1935 |
|
| 1936 |
#[test] |
| 1937 |
fn a_position_or_a_value_outside_the_track_is_clamped_to_it() { |
| 1938 |
let (min, max) = ATTACK; |
| 1939 |
let curve = Curve::Logarithmic { step: None }; |
| 1940 |
assert!((curve.value_at(-3.0, min, max) - min).abs() < 1e-12); |
| 1941 |
assert!((curve.value_at(4.0, min, max) - max).abs() < 1e-12); |
| 1942 |
assert!(curve.position_of(0.0, min, max).abs() < 1e-12); |
| 1943 |
assert!((curve.position_of(500.0, min, max) - 1.0).abs() < 1e-12); |
| 1944 |
} |
| 1945 |
|
| 1946 |
#[test] |
| 1947 |
fn a_typed_number_keeps_its_own_step_and_a_range_reads_its_curve() { |
| 1948 |
|
| 1949 |
|
| 1950 |
let typed = Field { |
| 1951 |
step: Some("5"), |
| 1952 |
..Field::new(FieldKind::Number, "port", "Port") |
| 1953 |
}; |
| 1954 |
assert_eq!(typed.step, Some("5")); |
| 1955 |
|
| 1956 |
let slid = Field { |
| 1957 |
curve: Curve::Logarithmic { |
| 1958 |
step: Some("0.001"), |
| 1959 |
}, |
| 1960 |
..Field::range("attack", "Attack", "0.001", "5") |
| 1961 |
}; |
| 1962 |
assert_eq!(slid.step, None); |
| 1963 |
assert_eq!(slid.curve.step(), Some("0.001")); |
| 1964 |
} |
| 1965 |
|
| 1966 |
#[test] |
| 1967 |
fn a_theme_picker_offers_themes_and_no_options() { |
| 1968 |
|
| 1969 |
|
| 1970 |
|
| 1971 |
let themes = [ |
| 1972 |
ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High), |
| 1973 |
ThemeChoice::new("dracula", "Dracula", ThemeVariant::Dark, Contrast::Standard), |
| 1974 |
]; |
| 1975 |
let field = Field::theme("theme", "Theme", &themes); |
| 1976 |
|
| 1977 |
assert_eq!(field.kind, FieldKind::Theme); |
| 1978 |
assert!(field.kind.offers_themes()); |
| 1979 |
assert!(!field.kind.offers_options()); |
| 1980 |
assert_eq!(field.themes.len(), 2); |
| 1981 |
assert!(field.options.is_empty()); |
| 1982 |
assert_eq!(field.follows, None); |
| 1983 |
} |
| 1984 |
|
| 1985 |
#[test] |
| 1986 |
fn following_carries_the_store_s_own_spelling() { |
| 1987 |
|
| 1988 |
|
| 1989 |
let field = |
| 1990 |
Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System")); |
| 1991 |
let follow = field.follows.expect("the row was offered"); |
| 1992 |
assert_eq!(follow.value, "system"); |
| 1993 |
assert_eq!(follow.label, "Follow System"); |
| 1994 |
} |
| 1995 |
|
| 1996 |
#[test] |
| 1997 |
fn a_picker_with_nothing_resolved_is_sayable() { |
| 1998 |
|
| 1999 |
|
| 2000 |
|
| 2001 |
let field = Field::theme("theme", "Theme", &[]); |
| 2002 |
assert!(field.themes.is_empty()); |
| 2003 |
} |
| 2004 |
|
| 2005 |
#[test] |
| 2006 |
fn every_kind_but_theme_offers_no_themes() { |
| 2007 |
for kind in [ |
| 2008 |
FieldKind::Text, |
| 2009 |
FieldKind::Select, |
| 2010 |
FieldKind::Radio, |
| 2011 |
FieldKind::Checkbox, |
| 2012 |
FieldKind::File, |
| 2013 |
FieldKind::Hidden, |
| 2014 |
] { |
| 2015 |
assert!( |
| 2016 |
!kind.offers_themes(), |
| 2017 |
"{kind:?} does not read Field::themes" |
| 2018 |
); |
| 2019 |
} |
| 2020 |
} |
| 2021 |
|
| 2022 |
#[test] |
| 2023 |
fn a_contrast_tier_reads_worst_first() { |
| 2024 |
|
| 2025 |
|
| 2026 |
assert!(Contrast::Low < Contrast::Standard); |
| 2027 |
assert!(Contrast::Standard < Contrast::High); |
| 2028 |
} |
| 2029 |
|
| 2030 |
#[test] |
| 2031 |
fn the_groups_and_badges_have_one_spelling_each() { |
| 2032 |
|
| 2033 |
|
| 2034 |
assert_eq!(ThemeVariant::Light.heading(), "Light"); |
| 2035 |
assert_eq!(ThemeVariant::Dark.heading(), "Dark"); |
| 2036 |
assert_eq!(ThemeVariant::HighContrast.heading(), "High Contrast"); |
| 2037 |
|
| 2038 |
assert_eq!(ThemeVariant::HighContrast.as_str(), "high-contrast"); |
| 2039 |
|
| 2040 |
assert_eq!(Contrast::High.badge(), "AA"); |
| 2041 |
assert_eq!(Contrast::Standard.badge(), "OK"); |
| 2042 |
assert_eq!(Contrast::Low.badge(), "low"); |
| 2043 |
} |
| 2044 |
|
| 2045 |
#[test] |
| 2046 |
fn the_variant_spelling_matches_the_theme_file_s_own() { |
| 2047 |
|
| 2048 |
|
| 2049 |
|
| 2050 |
for (variant, spelling) in [ |
| 2051 |
(ThemeVariant::Light, "light"), |
| 2052 |
(ThemeVariant::Dark, "dark"), |
| 2053 |
(ThemeVariant::HighContrast, "high-contrast"), |
| 2054 |
] { |
| 2055 |
assert_eq!(variant.as_str(), spelling); |
| 2056 |
assert_eq!(variant.to_string(), spelling); |
| 2057 |
} |
| 2058 |
} |
| 2059 |
|