Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(ui): splitter, scroll, dock coverage

Lewis: May this revision serve well! <lu5a@proton.me>

+775 -1
+687 -1
crates/bone-ui/src/layout/tests.rs
··· 1 - #![allow(dead_code, unused_imports)] 2 1 use core::num::{NonZeroU16, NonZeroU32, NonZeroU64}; 3 2 4 3 use super::axis::{Axis, CrossAxisAlign, MainAxisJustify}; ··· 249 248 } 250 249 251 250 #[test] 251 + fn splitter_uses_retained_fraction() { 252 + let id = wid(7); 253 + let mut retained = RetainedLayout::default(); 254 + retained.set_split(id, SplitFraction::clamped(0.25)); 255 + let layout = Layout::Splitter { 256 + id, 257 + axis: Axis::Horizontal, 258 + default_fraction: SplitFraction::HALF, 259 + a: Box::new(Layout::leaf(wid(1))), 260 + b: Box::new(Layout::leaf(wid(2))), 261 + }; 262 + let solved = solve(&layout, size(200.0, 50.0), &retained); 263 + let root = solved.root_node(); 264 + let a = solved.node(root.children[0]); 265 + let b = solved.node(root.children[1]); 266 + assert!( 267 + (a.rect.size.width.value() - 50.0).abs() < 1.0, 268 + "left should take 25%: {}", 269 + a.rect.size.width.value() 270 + ); 271 + assert!((b.rect.size.width.value() - 150.0).abs() < 1.0); 272 + let NodeKind::Splitter { fraction, .. } = root.kind else { 273 + panic!("expected splitter, got {:?}", root.kind); 274 + }; 275 + assert!((fraction.value() - 0.25).abs() < 1e-6); 276 + } 277 + 278 + #[test] 279 + fn splitter_default_fraction_when_unrecorded() { 280 + let id = wid(11); 281 + let layout = Layout::Splitter { 282 + id, 283 + axis: Axis::Vertical, 284 + default_fraction: SplitFraction::clamped(0.3), 285 + a: Box::new(Layout::leaf(wid(1))), 286 + b: Box::new(Layout::leaf(wid(2))), 287 + }; 288 + let solved = solve(&layout, size(40.0, 100.0), &RetainedLayout::default()); 289 + let a = solved.node(solved.root_node().children[0]); 290 + assert!((a.rect.size.height.value() - 30.0).abs() < 1.0); 291 + } 292 + 293 + #[test] 294 + fn splitter_keyboard_move_clamps_to_unit_interval() { 295 + let f = SplitFraction::clamped(0.05); 296 + let next = apply_keyboard_move( 297 + f, 298 + SplitFraction::HALF, 299 + SplitterMove::DecreaseA(SplitterStep::Coarse), 300 + ); 301 + assert_eq!(next, SplitFraction::clamped(0.0)); 302 + let max = apply_keyboard_move( 303 + SplitFraction::clamped(0.99), 304 + SplitFraction::HALF, 305 + SplitterMove::Max, 306 + ); 307 + assert!(approx_eq(max.value(), 1.0, 1e-6)); 308 + let reset = apply_keyboard_move( 309 + SplitFraction::clamped(0.1), 310 + SplitFraction::clamped(0.4), 311 + SplitterMove::Reset, 312 + ); 313 + assert!((reset.value() - 0.4).abs() < 1e-6); 314 + } 315 + 316 + #[test] 317 + fn splitter_drag_zero_total_falls_back_to_half() { 318 + assert_eq!( 319 + fraction_from_drag(LayoutPx::ZERO, LayoutPx::new(10.0)), 320 + SplitFraction::HALF 321 + ); 322 + } 323 + 324 + #[test] 325 + fn splitter_divider_paints_at_first_child_max_x() { 326 + let id = wid(12); 327 + let mut retained = RetainedLayout::default(); 328 + retained.set_split(id, SplitFraction::clamped(0.4)); 329 + let layout = Layout::Splitter { 330 + id, 331 + axis: Axis::Horizontal, 332 + default_fraction: SplitFraction::HALF, 333 + a: Box::new(Layout::leaf(wid(1))), 334 + b: Box::new(Layout::leaf(wid(2))), 335 + }; 336 + let solved = solve(&layout, size(200.0, 50.0), &retained); 337 + let plan = paint_plan(&solved, &Theme::light()); 338 + let divider = plan.commands.iter().find_map(|c| match c { 339 + PaintCommand::Divider { rect, .. } => Some(*rect), 340 + _ => None, 341 + }); 342 + let Some(divider) = divider else { 343 + panic!("divider command not emitted"); 344 + }; 345 + assert!( 346 + approx_eq(divider.min_x().value(), 80.0, 1.0), 347 + "divider should sit at A.max_x = 80px (40% of 200): got {}", 348 + divider.min_x().value() 349 + ); 350 + assert!(approx_eq(divider.size.height.value(), 50.0, PX_EPSILON)); 351 + } 352 + 353 + #[test] 354 + fn scroll_region_records_offset_and_content_size() { 355 + let id = wid(3); 356 + let mut retained = RetainedLayout::default(); 357 + retained.set_scroll(id, ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(12.0))); 358 + let inner = Layout::Column { 359 + gap: sp(0.0), 360 + justify: MainAxisJustify::Start, 361 + cross: CrossAxisAlign::Stretch, 362 + children: (0..10) 363 + .map(|_| Layout::Gap { size: sp(15.0) }) 364 + .chain(core::iter::once(Layout::leaf(wid(99)))) 365 + .collect(), 366 + }; 367 + let layout = Layout::ScrollRegion { 368 + id, 369 + axes: ScrollAxes::Vertical, 370 + child: Box::new(inner), 371 + }; 372 + let solved = solve(&layout, size(80.0, 60.0), &retained); 373 + let NodeKind::ScrollRegion { offset, axes, .. } = &solved.root_node().kind else { 374 + panic!("expected ScrollRegion, got {:?}", solved.root_node().kind); 375 + }; 376 + assert_eq!( 377 + *offset, 378 + ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(12.0)) 379 + ); 380 + assert!(matches!(axes, ScrollAxes::Vertical)); 381 + assert!(solved.root_node().content_size.height.value() >= 60.0); 382 + } 383 + 384 + #[test] 385 + fn clamp_scroll_caps_against_content_size() { 386 + let clamped = clamp_scroll( 387 + ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(500.0)), 388 + size(80.0, 100.0), 389 + size(80.0, 300.0), 390 + ScrollAxes::Vertical, 391 + ); 392 + assert!(approx_eq(clamped.y.value(), 200.0, PX_EPSILON)); 393 + assert!(approx_eq(clamped.x.value(), 0.0, PX_EPSILON)); 394 + } 395 + 396 + #[test] 397 + fn clamp_scroll_horizontal_axis_clamps_x() { 398 + let clamped = clamp_scroll( 399 + ScrollOffset::new(LayoutPx::new(800.0), LayoutPx::ZERO), 400 + size(200.0, 100.0), 401 + size(500.0, 100.0), 402 + ScrollAxes::Horizontal, 403 + ); 404 + assert!(approx_eq(clamped.x.value(), 300.0, PX_EPSILON)); 405 + assert!(approx_eq(clamped.y.value(), 0.0, PX_EPSILON)); 406 + } 407 + 408 + #[test] 409 + fn clamp_scroll_disallowed_axis_zeroes_request() { 410 + let clamped = clamp_scroll( 411 + ScrollOffset::new(LayoutPx::new(50.0), LayoutPx::new(999.0)), 412 + size(200.0, 100.0), 413 + size(500.0, 100.0), 414 + ScrollAxes::Horizontal, 415 + ); 416 + assert!(approx_eq(clamped.x.value(), 50.0, PX_EPSILON)); 417 + assert!(approx_eq(clamped.y.value(), 0.0, PX_EPSILON)); 418 + } 419 + 420 + #[test] 421 + fn clamp_scroll_both_axes_clamp_independently() { 422 + let clamped = clamp_scroll( 423 + ScrollOffset::new(LayoutPx::new(700.0), LayoutPx::new(700.0)), 424 + size(100.0, 100.0), 425 + size(400.0, 250.0), 426 + ScrollAxes::Both, 427 + ); 428 + assert!(approx_eq(clamped.x.value(), 300.0, PX_EPSILON)); 429 + assert!(approx_eq(clamped.y.value(), 150.0, PX_EPSILON)); 430 + } 431 + 432 + #[test] 433 + fn dock_solidworks_default_panel_set_is_complete() { 434 + let panels = ( 435 + pid(1), 436 + pid(2), 437 + pid(3), 438 + pid(4), 439 + pid(5), 440 + ); 441 + let Ok(dock) = DockState::solidworks_default(panels.0, panels.1, panels.2, panels.3, panels.4) 442 + else { 443 + panic!("solidworks_default rejected distinct ids"); 444 + }; 445 + let mut ids = dock.main.panel_ids(); 446 + ids.sort(); 447 + let mut expected = vec![panels.0, panels.1, panels.2, panels.3, panels.4]; 448 + expected.sort(); 449 + assert_eq!(ids, expected); 450 + } 451 + 452 + #[test] 453 + fn dock_host_layout_resolves_panels() { 454 + let feature = pid(1); 455 + let viewport = pid(2); 456 + let property = pid(3); 457 + let ribbon = pid(4); 458 + let status = pid(5); 459 + let Ok(dock) = DockState::solidworks_default(feature, property, ribbon, viewport, status) 460 + else { 461 + panic!("solidworks_default rejected distinct ids"); 462 + }; 463 + let panels = vec![ 464 + DockPanel { 465 + id: feature, 466 + child: Layout::leaf(wid(11)), 467 + }, 468 + DockPanel { 469 + id: property, 470 + child: Layout::leaf(wid(12)), 471 + }, 472 + DockPanel { 473 + id: ribbon, 474 + child: Layout::leaf(wid(13)), 475 + }, 476 + DockPanel { 477 + id: viewport, 478 + child: Layout::leaf(wid(14)), 479 + }, 480 + DockPanel { 481 + id: status, 482 + child: Layout::leaf(wid(15)), 483 + }, 484 + ]; 485 + let layout = Layout::DockHost { 486 + id: wid(99), 487 + state: dock, 488 + panels, 489 + tab_strip_height: sp(24.0), 490 + }; 491 + let solved = solve(&layout, size(1280.0, 800.0), &RetainedLayout::default()); 492 + assert!(matches!(solved.root_node().kind, NodeKind::DockHost { .. })); 493 + } 494 + 495 + #[test] 496 + fn dock_node_tabs_picks_active_panel() { 497 + let a = pid(10); 498 + let b = pid(11); 499 + let mut tabs = DockNode::tabs(vec![a, b]); 500 + if let DockNode::Tabs { active, .. } = &mut tabs { 501 + *active = TabIndex::new(1); 502 + } 503 + let layout = Layout::DockHost { 504 + id: wid(50), 505 + state: DockState::new(tabs), 506 + panels: vec![ 507 + DockPanel { 508 + id: a, 509 + child: Layout::leaf(wid(1)), 510 + }, 511 + DockPanel { 512 + id: b, 513 + child: Layout::leaf(wid(2)), 514 + }, 515 + ], 516 + tab_strip_height: sp(24.0), 517 + }; 518 + let solved = solve(&layout, size(120.0, 80.0), &RetainedLayout::default()); 519 + let leaf = solved.nodes.iter().find_map(|n| match &n.kind { 520 + NodeKind::Leaf(id) => Some(*id), 521 + _ => None, 522 + }); 523 + let Some(leaf) = leaf else { 524 + panic!("expected a leaf node in the solved layout"); 525 + }; 526 + assert_eq!(leaf, wid(2)); 527 + } 528 + 529 + #[test] 530 + fn dock_multi_tab_emits_tab_strip_with_active() { 531 + let a = pid(30); 532 + let b = pid(31); 533 + let c = pid(32); 534 + let mut tabs = DockNode::tabs(vec![a, b, c]); 535 + if let DockNode::Tabs { active, .. } = &mut tabs { 536 + *active = TabIndex::new(1); 537 + } 538 + let layout = Layout::DockHost { 539 + id: wid(80), 540 + state: DockState::new(tabs), 541 + panels: vec![ 542 + DockPanel { 543 + id: a, 544 + child: Layout::leaf(wid(1)), 545 + }, 546 + DockPanel { 547 + id: b, 548 + child: Layout::leaf(wid(2)), 549 + }, 550 + DockPanel { 551 + id: c, 552 + child: Layout::leaf(wid(3)), 553 + }, 554 + ], 555 + tab_strip_height: sp(24.0), 556 + }; 557 + let solved = solve(&layout, size(300.0, 200.0), &RetainedLayout::default()); 558 + let plan = paint_plan(&solved, &Theme::light()); 559 + let strip = plan.commands.iter().find_map(|c| match c { 560 + PaintCommand::TabStrip { active, tabs, .. } => Some((*active, tabs.clone())), 561 + _ => None, 562 + }); 563 + let Some((active, tabs)) = strip else { 564 + panic!("multi-tab dock did not emit TabStrip"); 565 + }; 566 + assert_eq!(active, b); 567 + assert_eq!(tabs, vec![a, b, c]); 568 + } 569 + 570 + #[test] 571 + fn dock_tab_index_out_of_range_falls_back_to_first() { 572 + let a = pid(40); 573 + let b = pid(41); 574 + let mut tabs = DockNode::tabs(vec![a, b]); 575 + if let DockNode::Tabs { active, .. } = &mut tabs { 576 + *active = TabIndex::new(99); 577 + } 578 + let layout = Layout::DockHost { 579 + id: wid(81), 580 + state: DockState::new(tabs), 581 + panels: vec![ 582 + DockPanel { 583 + id: a, 584 + child: Layout::leaf(wid(1)), 585 + }, 586 + DockPanel { 587 + id: b, 588 + child: Layout::leaf(wid(2)), 589 + }, 590 + ], 591 + tab_strip_height: sp(24.0), 592 + }; 593 + let solved = solve(&layout, size(120.0, 80.0), &RetainedLayout::default()); 594 + let leaf = solved.nodes.iter().find_map(|n| match &n.kind { 595 + NodeKind::Leaf(id) => Some(*id), 596 + _ => None, 597 + }); 598 + let Some(leaf) = leaf else { 599 + panic!("expected a leaf"); 600 + }; 601 + assert_eq!(leaf, wid(1)); 602 + } 603 + 604 + #[test] 605 + fn paint_plan_emits_clip_for_scroll_region() { 606 + let id = wid(4); 607 + let layout = Layout::ScrollRegion { 608 + id, 609 + axes: ScrollAxes::Both, 610 + child: Box::new(Layout::leaf(wid(5))), 611 + }; 612 + let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default()); 613 + let plan = paint_plan(&solved, &Theme::light()); 614 + let has_clip = plan 615 + .commands 616 + .iter() 617 + .any(|c| matches!(c, PaintCommand::PushClip { .. })); 618 + let has_pop = plan 619 + .commands 620 + .iter() 621 + .any(|c| matches!(c, PaintCommand::PopClip)); 622 + assert!(has_clip && has_pop); 623 + } 624 + 625 + #[test] 626 + fn paint_plan_emits_divider_for_splitter() { 627 + let layout = Layout::Splitter { 628 + id: wid(8), 629 + axis: Axis::Horizontal, 630 + default_fraction: SplitFraction::HALF, 631 + a: Box::new(Layout::leaf(wid(1))), 632 + b: Box::new(Layout::leaf(wid(2))), 633 + }; 634 + let solved = solve(&layout, size(200.0, 50.0), &RetainedLayout::default()); 635 + let plan = paint_plan(&solved, &Theme::light()); 636 + let dividers = plan 637 + .commands 638 + .iter() 639 + .filter(|c| matches!(c, PaintCommand::Divider { .. })) 640 + .count(); 641 + assert_eq!(dividers, 1); 642 + } 643 + 644 + #[test] 645 + fn split_fraction_rejects_non_unit_input() { 646 + assert!(SplitFraction::new(-0.1).is_err()); 647 + assert!(SplitFraction::new(1.1).is_err()); 648 + assert!(SplitFraction::new(f32::NAN).is_err()); 649 + } 650 + 651 + #[test] 252 652 fn grid_span_rect_rejects_non_increasing_lines() { 253 653 assert!( 254 654 GridSpan::rect( ··· 376 776 } 377 777 378 778 #[test] 779 + fn retained_layout_round_trips_through_ron() { 780 + let mut original = RetainedLayout::default(); 781 + original.set_scroll( 782 + wid(1), 783 + ScrollOffset::new(LayoutPx::new(4.0), LayoutPx::new(8.0)), 784 + ); 785 + original.set_split(wid(2), SplitFraction::clamped(0.42)); 786 + let Ok(serialised) = ron::ser::to_string(&original) else { 787 + panic!("retained layout serialise failed"); 788 + }; 789 + let Ok(parsed) = ron::de::from_str::<RetainedLayout>(&serialised) else { 790 + panic!("retained layout deserialise failed"); 791 + }; 792 + assert_eq!(original, parsed); 793 + } 794 + 795 + #[test] 796 + fn retained_layout_serialises_deterministically_regardless_of_insertion_order() { 797 + let entries = (1u8..=10) 798 + .map(|n| { 799 + ( 800 + wid(u64::from(n)), 801 + ScrollOffset::new(LayoutPx::new(f32::from(n)), LayoutPx::ZERO), 802 + ) 803 + }) 804 + .collect::<Vec<_>>(); 805 + let mut forward = RetainedLayout::default(); 806 + entries 807 + .iter() 808 + .fold((), |(), (id, off)| forward.set_scroll(*id, *off)); 809 + let mut backward = RetainedLayout::default(); 810 + entries 811 + .iter() 812 + .rev() 813 + .fold((), |(), (id, off)| backward.set_scroll(*id, *off)); 814 + let Ok(forward_ron) = ron::ser::to_string(&forward) else { 815 + panic!("forward serialise failed"); 816 + }; 817 + let Ok(reverse_ron) = ron::ser::to_string(&backward) else { 818 + panic!("backward serialise failed"); 819 + }; 820 + assert_eq!(forward_ron, reverse_ron); 821 + } 822 + 823 + #[test] 824 + fn dock_host_with_empty_tabs_returns_empty_dock_tabs_error() { 825 + let layout = Layout::DockHost { 826 + id: wid(60), 827 + state: DockState::new(DockNode::tabs(Vec::new())), 828 + panels: Vec::new(), 829 + tab_strip_height: sp(24.0), 830 + }; 831 + let result = measure(&layout, size(100.0, 100.0), &RetainedLayout::default()); 832 + assert!(matches!(result, Err(LayoutError::EmptyDockTabs))); 833 + } 834 + 835 + #[test] 836 + fn dock_host_paint_emits_surface_for_each_panel() { 837 + let a = pid(20); 838 + let b = pid(21); 839 + let dock = DockState::new(DockNode::split( 840 + Axis::Horizontal, 841 + SplitFraction::HALF, 842 + DockNode::tabs(vec![a]), 843 + DockNode::tabs(vec![b]), 844 + )); 845 + let layout = Layout::DockHost { 846 + id: wid(70), 847 + state: dock, 848 + panels: vec![ 849 + DockPanel { 850 + id: a, 851 + child: Layout::leaf(wid(1)), 852 + }, 853 + DockPanel { 854 + id: b, 855 + child: Layout::leaf(wid(2)), 856 + }, 857 + ], 858 + tab_strip_height: sp(24.0), 859 + }; 860 + let solved = solve(&layout, size(400.0, 300.0), &RetainedLayout::default()); 861 + let plan = paint_plan(&solved, &Theme::light()); 862 + let panel_slots = plan 863 + .commands 864 + .iter() 865 + .filter(|c| matches!(c, PaintCommand::PanelSlot { .. })) 866 + .count(); 867 + let dividers = plan 868 + .commands 869 + .iter() 870 + .filter(|c| matches!(c, PaintCommand::Divider { .. })) 871 + .count(); 872 + assert_eq!(panel_slots, 2); 873 + assert_eq!(dividers, 1); 874 + } 875 + 876 + #[test] 877 + fn paint_plan_balances_translate_pairs_for_scroll_region() { 878 + let layout = Layout::scroll_region(wid(101), ScrollAxes::Both, Layout::leaf(wid(102))); 879 + let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default()); 880 + let plan = paint_plan(&solved, &Theme::light()); 881 + let translates = plan 882 + .commands 883 + .iter() 884 + .filter(|c| matches!(c, PaintCommand::Translate { .. })) 885 + .count(); 886 + let untranslates = plan 887 + .commands 888 + .iter() 889 + .filter(|c| matches!(c, PaintCommand::Untranslate)) 890 + .count(); 891 + let pushes = plan 892 + .commands 893 + .iter() 894 + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 895 + .count(); 896 + let pops = plan 897 + .commands 898 + .iter() 899 + .filter(|c| matches!(c, PaintCommand::PopClip)) 900 + .count(); 901 + assert_eq!(translates, 1); 902 + assert_eq!(untranslates, 1); 903 + assert_eq!(pushes, 1); 904 + assert_eq!(pops, 1); 905 + } 906 + 907 + #[test] 908 + fn paint_plan_nested_scroll_regions_emit_balanced_paired_commands() { 909 + let inner = Layout::scroll_region(wid(201), ScrollAxes::Vertical, Layout::leaf(wid(202))); 910 + let outer = Layout::scroll_region(wid(200), ScrollAxes::Both, inner); 911 + let solved = solve(&outer, size(80.0, 80.0), &RetainedLayout::default()); 912 + let plan = paint_plan(&solved, &Theme::light()); 913 + let pushes = plan 914 + .commands 915 + .iter() 916 + .filter(|c| matches!(c, PaintCommand::PushClip { .. })) 917 + .count(); 918 + let pops = plan 919 + .commands 920 + .iter() 921 + .filter(|c| matches!(c, PaintCommand::PopClip)) 922 + .count(); 923 + let translates = plan 924 + .commands 925 + .iter() 926 + .filter(|c| matches!(c, PaintCommand::Translate { .. })) 927 + .count(); 928 + let untranslates = plan 929 + .commands 930 + .iter() 931 + .filter(|c| matches!(c, PaintCommand::Untranslate)) 932 + .count(); 933 + assert_eq!(pushes, 2); 934 + assert_eq!(pops, 2); 935 + assert_eq!(translates, 2); 936 + assert_eq!(untranslates, 2); 937 + let depth = plan 938 + .commands 939 + .iter() 940 + .try_fold(0i32, |d, c| match c { 941 + PaintCommand::PushClip { .. } | PaintCommand::Translate { .. } => Some(d + 1), 942 + PaintCommand::PopClip | PaintCommand::Untranslate => { 943 + if d <= 0 { 944 + None 945 + } else { 946 + Some(d - 1) 947 + } 948 + } 949 + _ => Some(d), 950 + }); 951 + assert_eq!(depth, Some(0), "push/pop pairs must nest correctly"); 952 + } 953 + 954 + #[test] 955 + fn dock_state_round_trip_preserves_floating_surfaces() { 956 + let main = DockNode::tabs(vec![pid(1)]); 957 + let floating_root = DockNode::split( 958 + Axis::Horizontal, 959 + SplitFraction::clamped(0.4), 960 + DockNode::tabs(vec![pid(2)]), 961 + DockNode::tabs(vec![pid(3)]), 962 + ); 963 + let original = DockState { 964 + main, 965 + floating: vec![FloatingSurface { 966 + root: floating_root, 967 + origin: LayoutPos::new(LayoutPx::new(120.0), LayoutPx::new(80.0)), 968 + size: LayoutSize::new(LayoutPx::new(640.0), LayoutPx::new(480.0)), 969 + }], 970 + }; 971 + let Ok(serialised) = ron::ser::to_string(&original) else { 972 + panic!("dock state serialise failed"); 973 + }; 974 + let Ok(parsed) = ron::de::from_str::<DockState>(&serialised) else { 975 + panic!("dock state deserialise failed"); 976 + }; 977 + assert_eq!(parsed, original); 978 + assert_eq!(parsed.floating.len(), 1); 979 + assert_eq!(parsed.floating[0].root.panel_ids(), vec![pid(2), pid(3)]); 980 + } 981 + 982 + #[test] 379 983 fn grid_duplicate_track_name_errors() { 380 984 const SHARED: TrackName = TrackName::new("dup"); 381 985 let span = GridSpan { ··· 532 1136 } 533 1137 534 1138 #[test] 1139 + fn scroll_region_clamps_retained_offset_against_content() { 1140 + let id = wid(50); 1141 + let mut retained = RetainedLayout::default(); 1142 + retained.set_scroll(id, ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(10_000.0))); 1143 + let inner = Layout::Column { 1144 + gap: sp(0.0), 1145 + justify: MainAxisJustify::Start, 1146 + cross: CrossAxisAlign::Stretch, 1147 + children: (0..5).map(|_| Layout::Gap { size: sp(20.0) }).collect(), 1148 + }; 1149 + let layout = Layout::ScrollRegion { 1150 + id, 1151 + axes: ScrollAxes::Vertical, 1152 + child: Box::new(inner), 1153 + }; 1154 + let solved = solve(&layout, size(80.0, 40.0), &retained); 1155 + let root = solved.root_node(); 1156 + let NodeKind::ScrollRegion { offset, .. } = &root.kind else { 1157 + panic!("expected ScrollRegion"); 1158 + }; 1159 + let max_y = (root.content_size.height.value() - root.rect.size.height.value()).max(0.0); 1160 + assert!( 1161 + approx_eq(offset.y.value(), max_y, PX_EPSILON), 1162 + "y must clamp to content_size - viewport ({max_y}); got {}", 1163 + offset.y.value() 1164 + ); 1165 + assert!(offset.y.value() < 10_000.0); 1166 + } 1167 + 1168 + #[test] 1169 + fn solidworks_default_rejects_duplicate_panel_ids() { 1170 + let result = DockState::solidworks_default(pid(1), pid(2), pid(1), pid(4), pid(5)); 1171 + let Err(DockStateError::DuplicatePanelId(p)) = result else { 1172 + panic!("expected DuplicatePanelId, got {result:?}"); 1173 + }; 1174 + assert_eq!(p, pid(1)); 1175 + } 1176 + 1177 + #[test] 1178 + fn dock_host_with_floating_surfaces_returns_unsupported_error() { 1179 + let main = DockNode::tabs(vec![pid(1)]); 1180 + let floating = FloatingSurface { 1181 + root: DockNode::tabs(vec![pid(2)]), 1182 + origin: LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(50.0)), 1183 + size: LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(150.0)), 1184 + }; 1185 + let state = DockState { 1186 + main, 1187 + floating: vec![floating], 1188 + }; 1189 + let layout = Layout::DockHost { 1190 + id: wid(60), 1191 + state, 1192 + panels: vec![DockPanel { 1193 + id: pid(1), 1194 + child: Layout::leaf(wid(1)), 1195 + }], 1196 + tab_strip_height: sp(24.0), 1197 + }; 1198 + let result = measure(&layout, size(200.0, 200.0), &RetainedLayout::default()); 1199 + assert!(matches!(result, Err(LayoutError::UnsupportedFloatingSurfaces))); 1200 + } 1201 + 1202 + #[test] 535 1203 fn row_main_axis_end_pushes_children_to_right_edge() { 536 1204 let layout = Layout::Row { 537 1205 gap: sp(0.0), ··· 578 1246 }; 579 1247 assert!(approx_eq(parsed.value(), 12.5, PX_EPSILON)); 580 1248 } 1249 + 1250 + #[test] 1251 + fn retained_layout_rejects_non_finite_scroll_offset_at_deserialize() { 1252 + let poisoned = "(scroll: {(1): (x: NaN, y: 0.0)}, split: {})"; 1253 + assert!(ron::de::from_str::<RetainedLayout>(poisoned).is_err()); 1254 + } 1255 + 1256 + #[test] 1257 + fn split_fraction_clamped_is_const_safe_for_constants() { 1258 + const A: SplitFraction = SplitFraction::clamped(0.22); 1259 + const B: SplitFraction = SplitFraction::clamped(-1.0); 1260 + const C: SplitFraction = SplitFraction::clamped(2.0); 1261 + const D: SplitFraction = SplitFraction::clamped(f32::NAN); 1262 + assert!(approx_eq(A.value(), 0.22, 1e-6)); 1263 + assert!(approx_eq(B.value(), 0.0, 1e-6)); 1264 + assert!(approx_eq(C.value(), 1.0, 1e-6)); 1265 + assert!(approx_eq(D.value(), 0.5, 1e-6)); 1266 + }
+35
crates/bone-ui/tests/dock_snapshot.rs
··· 1 + use core::num::NonZeroU32; 2 + 3 + use bone_ui::layout::{DockState, PanelId}; 4 + 5 + fn pid(n: u32) -> PanelId { 6 + PanelId::new(NonZeroU32::new(n).unwrap_or(NonZeroU32::MIN)) 7 + } 8 + 9 + fn solidworks_default() -> DockState { 10 + let Ok(state) = DockState::solidworks_default(pid(1), pid(2), pid(3), pid(4), pid(5)) else { 11 + panic!("solidworks_default rejected distinct ids"); 12 + }; 13 + state 14 + } 15 + 16 + #[test] 17 + fn solidworks_default_dock_layout_snapshot() { 18 + insta::assert_ron_snapshot!("dock_solidworks_default", solidworks_default()); 19 + } 20 + 21 + #[test] 22 + fn solidworks_default_dock_round_trip_is_stable() { 23 + let original = solidworks_default(); 24 + let Ok(first) = ron::ser::to_string(&original) else { 25 + panic!("first serialise failed"); 26 + }; 27 + let Ok(parsed) = ron::de::from_str::<DockState>(&first) else { 28 + panic!("deserialise failed"); 29 + }; 30 + let Ok(second) = ron::ser::to_string(&parsed) else { 31 + panic!("second serialise failed"); 32 + }; 33 + assert_eq!(first, second); 34 + assert_eq!(original, parsed); 35 + }
+53
crates/bone-ui/tests/snapshots/dock_snapshot__dock_solidworks_default.snap
··· 1 + --- 2 + source: crates/bone-ui/tests/dock_snapshot.rs 3 + expression: state 4 + --- 5 + DockState( 6 + main: Split( 7 + axis: Vertical, 8 + fraction: 0.97, 9 + a: Split( 10 + axis: Vertical, 11 + fraction: 0.1, 12 + a: Tabs( 13 + tabs: [ 14 + 3, 15 + ], 16 + active: 0, 17 + ), 18 + b: Split( 19 + axis: Horizontal, 20 + fraction: 0.22, 21 + a: Tabs( 22 + tabs: [ 23 + 1, 24 + ], 25 + active: 0, 26 + ), 27 + b: Split( 28 + axis: Horizontal, 29 + fraction: 0.78, 30 + a: Tabs( 31 + tabs: [ 32 + 4, 33 + ], 34 + active: 0, 35 + ), 36 + b: Tabs( 37 + tabs: [ 38 + 2, 39 + ], 40 + active: 0, 41 + ), 42 + ), 43 + ), 44 + ), 45 + b: Tabs( 46 + tabs: [ 47 + 5, 48 + ], 49 + active: 0, 50 + ), 51 + ), 52 + floating: [], 53 + )