Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

chore: reorganize tests

RivoLink fdde1b3f 3147e80a

+720 -697
+5 -697
src/tests.rs src/tests/app.rs
··· 1 - use crate::app::FileChange; 2 - use crate::markdown::{ 3 - hash_str, parse_markdown, parse_markdown_with_width, read_file_state, resolve_syntax, 4 - }; 5 - use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 6 - use crate::update::TestAsset; 1 + use super::{test_assets, unique_temp_dir}; 2 + use crate::app::{App, AppConfig, FileChange}; 3 + use crate::cli::parse_cli; 4 + use crate::markdown::{hash_str, parse_markdown, parse_markdown_with_width, read_file_state}; 7 5 use crate::*; 8 6 use crossterm::event::KeyEventKind; 9 - use ratatui::backend::TestBackend; 10 - use ratatui::{text::Line, widgets::Paragraph, Terminal}; 11 7 use std::{ 12 8 fs, 13 - path::PathBuf, 14 - sync::{Mutex, MutexGuard}, 15 9 time::{SystemTime, UNIX_EPOCH}, 16 10 }; 17 - use syntect::{ 18 - highlighting::{Theme, ThemeSet}, 19 - parsing::SyntaxSet, 20 - }; 21 - 22 - static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); 23 - 24 - fn test_assets() -> (SyntaxSet, Theme) { 25 - let ss = SyntaxSet::load_defaults_newlines(); 26 - let ts = ThemeSet::load_defaults(); 27 - let theme = ts.themes["base16-ocean.dark"].clone(); 28 - (ss, theme) 29 - } 30 - 31 - fn render_buffer(lines: &[Line<'static>]) -> ratatui::buffer::Buffer { 32 - let width = lines 33 - .iter() 34 - .map(|line| line.width()) 35 - .max() 36 - .unwrap_or(1) 37 - .max(1) as u16; 38 - let height = lines.len().max(1) as u16; 39 - let backend = TestBackend::new(width, height); 40 - let mut terminal = Terminal::new(backend).unwrap(); 41 - terminal 42 - .draw(|f| { 43 - f.render_widget(Paragraph::new(lines.to_vec()), f.area()); 44 - }) 45 - .unwrap(); 46 - terminal.backend().buffer().clone() 47 - } 48 - 49 - fn find_symbol(buffer: &ratatui::buffer::Buffer, symbol: &str) -> Option<(u16, u16)> { 50 - for y in 0..buffer.area.height { 51 - for x in 0..buffer.area.width { 52 - if buffer 53 - .cell((x, y)) 54 - .is_some_and(|cell| cell.symbol() == symbol) 55 - { 56 - return Some((x, y)); 57 - } 58 - } 59 - } 60 - None 61 - } 62 - 63 - fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { 64 - lines 65 - .iter() 66 - .map(line_plain_text) 67 - .filter(|line| !line.is_empty()) 68 - .collect() 69 - } 70 - 71 - fn lock_theme_test_state() -> MutexGuard<'static, ()> { 72 - THEME_TEST_MUTEX.lock().unwrap() 73 - } 74 - 75 - fn unique_temp_dir(prefix: &str) -> PathBuf { 76 - let unique = SystemTime::now() 77 - .duration_since(UNIX_EPOCH) 78 - .unwrap() 79 - .as_nanos(); 80 - std::env::temp_dir().join(format!("{prefix}-{unique}")) 81 - } 11 + use syntect::highlighting::ThemeSet; 82 12 83 13 #[test] 84 14 fn search_matches_across_span_boundaries() { ··· 156 86 } 157 87 158 88 #[test] 159 - fn asset_name_matches_supported_release_targets() { 160 - assert_eq!( 161 - asset_name_for_target("macos", "x86_64"), 162 - Some("leaf-macos-x86_64") 163 - ); 164 - assert_eq!( 165 - asset_name_for_target("macos", "aarch64"), 166 - Some("leaf-macos-arm64") 167 - ); 168 - assert_eq!( 169 - asset_name_for_target("linux", "x86_64"), 170 - Some("leaf-linux-x86_64") 171 - ); 172 - assert_eq!( 173 - asset_name_for_target("linux", "aarch64"), 174 - Some("leaf-linux-arm64") 175 - ); 176 - assert_eq!( 177 - asset_name_for_target("android", "aarch64"), 178 - Some("leaf-android-arm64") 179 - ); 180 - assert_eq!( 181 - asset_name_for_target("windows", "x86_64"), 182 - Some("leaf-windows-x86_64.exe") 183 - ); 184 - assert_eq!(asset_name_for_target("linux", "arm"), None); 185 - } 186 - 187 - #[test] 188 - fn newer_version_comparison_accepts_optional_v_prefix() { 189 - assert!(is_newer_version("1.4.2", "v1.4.3").unwrap()); 190 - assert!(!is_newer_version("1.4.2", "1.4.2").unwrap()); 191 - assert!(!is_newer_version("1.4.2", "1.4.1").unwrap()); 192 - } 193 - 194 - #[test] 195 - fn expected_asset_download_url_selects_matching_asset() { 196 - let assets = vec![ 197 - TestAsset { 198 - name: "leaf-linux-x86_64", 199 - download_url: "https://example.test/linux", 200 - }, 201 - TestAsset { 202 - name: "leaf-windows-x86_64.exe", 203 - download_url: "https://example.test/windows", 204 - }, 205 - ]; 206 - 207 - let url = expected_asset_download_url("1.4.3", &assets, "leaf-linux-x86_64").unwrap(); 208 - assert_eq!(url, "https://example.test/linux"); 209 - } 210 - 211 - #[test] 212 - fn expected_asset_download_url_errors_when_asset_is_missing() { 213 - let assets = vec![TestAsset { 214 - name: "leaf-linux-x86_64", 215 - download_url: "https://example.test/linux", 216 - }]; 217 - 218 - let err = expected_asset_download_url("1.4.3", &assets, "leaf-macos-arm64").unwrap_err(); 219 - assert!(err.to_string().contains("does not contain asset")); 220 - } 221 - 222 - #[test] 223 - fn validate_download_size_accepts_matching_non_zero_sizes() { 224 - assert!(validate_download_size(Some(42), 42).is_ok()); 225 - assert!(validate_download_size(None, 42).is_ok()); 226 - } 227 - 228 - #[test] 229 - fn validate_download_size_rejects_zero_or_mismatched_sizes() { 230 - let empty_err = validate_download_size(None, 0).unwrap_err(); 231 - assert!(empty_err.to_string().contains("is empty")); 232 - 233 - let mismatch_err = validate_download_size(Some(42), 41).unwrap_err(); 234 - assert!(mismatch_err.to_string().contains("size mismatch")); 235 - } 236 - 237 - #[test] 238 - fn find_expected_checksum_extracts_matching_asset_checksum() { 239 - let checksums = "\ 240 - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa leaf-linux-x86_64 241 - bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb leaf-windows-x86_64.exe 242 - "; 243 - 244 - let checksum = find_expected_checksum(checksums, "leaf-windows-x86_64.exe").unwrap(); 245 - assert_eq!( 246 - checksum, 247 - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 248 - ); 249 - } 250 - 251 - #[test] 252 - fn find_expected_checksum_rejects_missing_or_invalid_entries() { 253 - let missing = 254 - find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64").unwrap_err(); 255 - assert!(missing.to_string().contains("does not contain")); 256 - 257 - let invalid = 258 - find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64").unwrap_err(); 259 - assert!(invalid 260 - .to_string() 261 - .contains("Invalid SHA256 checksum format")); 262 - } 263 - 264 - #[test] 265 - fn validate_sha256_hex_accepts_expected_format() { 266 - assert!(validate_sha256_hex( 267 - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 268 - ) 269 - .is_ok()); 270 - } 271 - 272 - #[test] 273 89 fn cancelling_search_clears_query_and_matches() { 274 90 let (ss, theme) = test_assets(); 275 91 let (lines, toc) = parse_markdown("alpha\nbeta\nalpha beta\n", &ss, &theme); ··· 398 214 let app = App::new(lines, toc, "stdin".to_string(), false, false, None, None); 399 215 400 216 assert_eq!(app.active_highlight_line(), None); 401 - } 402 - 403 - #[test] 404 - fn code_block_box_renders_right_border_in_one_column() { 405 - let (ss, theme) = test_assets(); 406 - let md = "```ts\nconst city = \"東京\";\n\tconsole.log(city)\n```"; 407 - let (lines, _) = parse_markdown(md, &ss, &theme); 408 - let buffer = render_buffer(&lines); 409 - 410 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 411 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 412 - 413 - for y in start_y + 1..end_y { 414 - assert_eq!( 415 - buffer.cell((right_x, y)).unwrap().symbol(), 416 - "│", 417 - "missing code block right border at row {y}" 418 - ); 419 - } 420 - } 421 - 422 - #[test] 423 - fn table_render_right_border_stays_aligned() { 424 - let (ss, theme) = test_assets(); 425 - let md = "| Name | Value |\n| --- | --- |\n| 東京 | 12 |\n| tab\tcell | ok |"; 426 - let (lines, _) = parse_markdown(md, &ss, &theme); 427 - let buffer = render_buffer(&lines); 428 - 429 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 430 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 431 - 432 - for y in start_y + 1..end_y { 433 - let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 434 - assert!( 435 - matches!(symbol, "│" | "┤" | "╡"), 436 - "unexpected table edge symbol {symbol:?} at row {y}" 437 - ); 438 - } 439 - } 440 - 441 - #[test] 442 - fn table_render_right_border_stays_aligned_with_emoji_cells() { 443 - let (ss, theme) = test_assets(); 444 - let md = "| Critère | Note |\n| --- | --- |\n| Tests | ✅ Bonne couverture |\n| Sécurité | ⚠ Quelques points |\n"; 445 - let (lines, _) = parse_markdown(md, &ss, &theme); 446 - let buffer = render_buffer(&lines); 447 - 448 - let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 449 - let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 450 - 451 - for y in start_y + 1..end_y { 452 - let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 453 - assert!( 454 - matches!(symbol, "│" | "┤" | "╡"), 455 - "unexpected emoji-table edge symbol {symbol:?} at row {y}" 456 - ); 457 - } 458 - } 459 - 460 - #[test] 461 - fn narrow_tables_fit_render_width_and_wrap_cells() { 462 - let (ss, theme) = test_assets(); 463 - let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; 464 - let (lines, _) = parse_markdown_with_width(md, &ss, &theme, 36); 465 - let rendered = rendered_non_empty_lines(&lines); 466 - 467 - assert!(rendered.len() >= 6); 468 - assert!(rendered.iter().all(|line| display_width(line) <= 36)); 469 - } 470 - 471 - #[test] 472 - fn h1_headings_render_double_rule_without_bottom_spacing() { 473 - let (ss, theme) = test_assets(); 474 - let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 475 - let rendered = rendered_non_empty_lines(&lines); 476 - 477 - assert_eq!(rendered[0], "東京"); 478 - assert_eq!(rendered[1], "═".repeat(display_width("東京"))); 479 - } 480 - 481 - #[test] 482 - fn loose_list_items_keep_their_markers() { 483 - let (ss, theme) = test_assets(); 484 - let (lines, _) = parse_markdown("- first\n\n- second\n", &ss, &theme); 485 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 486 - 487 - assert!(rendered.iter().any(|line| line.contains("• first"))); 488 - assert!(rendered.iter().any(|line| line.contains("• second"))); 489 - } 490 - 491 - #[test] 492 - fn ordered_lists_render_numeric_markers() { 493 - let (ss, theme) = test_assets(); 494 - let (lines, _) = parse_markdown("3. third\n4. fourth\n", &ss, &theme); 495 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 496 - 497 - assert!(rendered.iter().any(|line| line.contains("3. third"))); 498 - assert!(rendered.iter().any(|line| line.contains("4. fourth"))); 499 - } 500 - 501 - #[test] 502 - fn multiline_list_items_keep_marker_only_on_first_line() { 503 - let (ss, theme) = test_assets(); 504 - let (lines, _) = parse_markdown("- first line\n second line\n", &ss, &theme); 505 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 506 - 507 - let first = rendered 508 - .iter() 509 - .find(|line| line.contains("first line")) 510 - .unwrap(); 511 - let second = rendered 512 - .iter() 513 - .find(|line| line.contains("second line")) 514 - .unwrap(); 515 - 516 - assert!(first.contains("• first line")); 517 - assert!(!second.contains('•')); 518 - assert!(second.starts_with(" ")); 519 - } 520 - 521 - #[test] 522 - fn ordered_lists_preserve_non_default_start_numbers() { 523 - let (ss, theme) = test_assets(); 524 - let (lines, _) = parse_markdown("7. seven\n8. eight\n", &ss, &theme); 525 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 526 - 527 - assert!(rendered.iter().any(|line| line.contains("7. seven"))); 528 - assert!(rendered.iter().any(|line| line.contains("8. eight"))); 529 - } 530 - 531 - #[test] 532 - fn loose_list_items_render_expected_lines() { 533 - let (ss, theme) = test_assets(); 534 - let src = "- first loose item\n\n- second loose item after a blank line\n\n- third loose item\n\n continuation paragraph\n"; 535 - let (lines, _) = parse_markdown(src, &ss, &theme); 536 - let rendered = rendered_non_empty_lines(&lines); 537 - 538 - assert_eq!( 539 - rendered, 540 - vec![ 541 - "• first loose item", 542 - "• second loose item after a blank line", 543 - "• third loose item", 544 - " continuation paragraph", 545 - ] 546 - ); 547 - } 548 - 549 - #[test] 550 - fn ordered_loose_lists_render_expected_lines() { 551 - let (ss, theme) = test_assets(); 552 - let src = "7. seventh item\n\n8. eighth item\n\n continuation paragraph\n"; 553 - let (lines, _) = parse_markdown(src, &ss, &theme); 554 - let rendered = rendered_non_empty_lines(&lines); 555 - 556 - assert_eq!( 557 - rendered, 558 - vec![ 559 - "7. seventh item", 560 - "8. eighth item", 561 - " continuation paragraph", 562 - ] 563 - ); 564 - } 565 - 566 - #[test] 567 - fn ordered_lists_render_expected_lines() { 568 - let (ss, theme) = test_assets(); 569 - let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 570 - let rendered = rendered_non_empty_lines(&lines); 571 - 572 - assert_eq!(rendered, vec!["3. third item", "4. fourth item"]); 573 - } 574 - 575 - #[test] 576 - fn paragraph_and_following_list_have_no_blank_gap() { 577 - let (ss, theme) = test_assets(); 578 - let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 579 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 580 - let intro_idx = rendered 581 - .iter() 582 - .position(|line| line == "Intro paragraph") 583 - .unwrap(); 584 - 585 - assert_eq!(rendered[intro_idx + 1], "• first"); 586 - } 587 - 588 - #[test] 589 - fn wrapped_list_items_align_continuation_under_text() { 590 - let (ss, theme) = test_assets(); 591 - let src = "- First item with enough text to wrap when the terminal is narrow and show continuation alignment.\n8. Eighth item with enough text to wrap and keep numeric alignment readable.\n"; 592 - let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 36); 593 - let rendered = rendered_non_empty_lines(&lines); 594 - 595 - assert!(rendered.iter().any(|line| line.starts_with("• First item"))); 596 - assert!(rendered 597 - .iter() 598 - .any(|line| line.starts_with(" ") && line.contains("terminal is narrow"))); 599 - assert!(rendered 600 - .iter() 601 - .any(|line| line.starts_with("8. Eighth item"))); 602 - assert!(rendered 603 - .iter() 604 - .any(|line| line.starts_with(" ") && !line.starts_with("8. "))); 605 - } 606 - 607 - #[test] 608 - fn paragraph_and_following_code_block_have_no_blank_gap() { 609 - let (ss, theme) = test_assets(); 610 - let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 611 - let (lines, _) = parse_markdown(src, &ss, &theme); 612 - let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 613 - let intro_idx = rendered 614 - .iter() 615 - .position(|line| line == "Intro paragraph") 616 - .unwrap(); 617 - 618 - assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 619 - } 620 - 621 - #[test] 622 - fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { 623 - let (ss, theme) = test_assets(); 624 - let src = "> outer\n> > inner\n> outer again\n"; 625 - let (lines, _) = parse_markdown(src, &ss, &theme); 626 - let rendered = rendered_non_empty_lines(&lines); 627 - 628 - assert!(rendered.iter().any(|line| line == "▏ outer")); 629 - assert!(rendered.iter().any(|line| line == "▏ inner")); 630 - assert!(rendered.iter().any(|line| line == "▏ outer again")); 631 - } 632 - 633 - #[test] 634 - fn long_blockquotes_wrap_into_multiple_prefixed_lines() { 635 - let (ss, theme) = test_assets(); 636 - let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; 637 - let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 28); 638 - let rendered = rendered_non_empty_lines(&lines); 639 - let quoted: Vec<_> = rendered 640 - .into_iter() 641 - .filter(|line| line.starts_with('▏')) 642 - .collect(); 643 - 644 - assert!(quoted.len() >= 2); 645 - assert!(quoted.iter().all(|line| line.starts_with("▏ "))); 646 - } 647 - 648 - #[test] 649 - fn toc_only_includes_first_two_heading_levels() { 650 - let (ss, theme) = test_assets(); 651 - let (_, toc) = parse_markdown("# One\n## Two\n### Three\n#### Four\n", &ss, &theme); 652 - 653 - assert_eq!(toc.len(), 3); 654 - assert_eq!(toc[0].level, 1); 655 - assert_eq!(toc[1].level, 2); 656 - assert_eq!(toc[2].level, 3); 657 - } 658 - 659 - #[test] 660 - fn frontmatter_is_ignored_in_preview_and_toc() { 661 - let (ss, theme) = test_assets(); 662 - let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; 663 - let (lines, toc) = parse_markdown(src, &ss, &theme); 664 - let rendered = rendered_non_empty_lines(&lines); 665 - 666 - assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); 667 - assert!(rendered.iter().any(|line| line.contains("Visible"))); 668 - assert_eq!(toc.len(), 1); 669 - assert_eq!(toc[0].title, "Visible"); 670 - } 671 - 672 - #[test] 673 - fn h2_headings_are_underlined_and_compact() { 674 - let (ss, theme) = test_assets(); 675 - let (lines, _) = parse_markdown_with_width("Intro\n\n## Section\nBody\n", &ss, &theme, 40); 676 - let rendered = rendered_non_empty_lines(&lines); 677 - 678 - assert!(rendered.iter().any(|line| line.contains("Section"))); 679 - assert!(rendered.iter().any(|line| line.contains("────"))); 680 - } 681 - 682 - #[test] 683 - fn rules_use_render_width_without_extra_blank_after() { 684 - let (ss, theme) = test_assets(); 685 - let (lines, _) = parse_markdown_with_width("Alpha\n\n---\nBeta\n", &ss, &theme, 24); 686 - let rendered = rendered_non_empty_lines(&lines); 687 - let rule = rendered 688 - .iter() 689 - .find(|line| line.trim_start().starts_with('─')) 690 - .unwrap(); 691 - 692 - assert_eq!(display_width(rule.trim_start()), 24); 693 - let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); 694 - assert_eq!(rendered[rule_idx + 1], "Beta"); 695 - } 696 - 697 - #[test] 698 - fn toc_hides_single_h1_when_h2_entries_exist() { 699 - let toc = vec![ 700 - TocEntry { 701 - level: 1, 702 - title: "Doc Title".to_string(), 703 - line: 0, 704 - }, 705 - TocEntry { 706 - level: 2, 707 - title: "Install".to_string(), 708 - line: 10, 709 - }, 710 - ]; 711 - 712 - assert!(should_hide_single_h1(&toc)); 713 - assert_eq!(toc_display_level(2, true, false), 1); 714 - assert_eq!(toc_display_level(3, true, false), 2); 715 - } 716 - 717 - #[test] 718 - fn toc_keeps_single_h1_when_no_h2_entries_exist() { 719 - let toc = vec![TocEntry { 720 - level: 1, 721 - title: "Doc Title".to_string(), 722 - line: 0, 723 - }]; 724 - 725 - assert!(!should_hide_single_h1(&toc)); 726 - } 727 - 728 - #[test] 729 - fn toc_promotes_h2_when_document_has_no_h1() { 730 - let toc = vec![ 731 - TocEntry { 732 - level: 2, 733 - title: "Build & install".to_string(), 734 - line: 0, 735 - }, 736 - TocEntry { 737 - level: 3, 738 - title: "Android".to_string(), 739 - line: 4, 740 - }, 741 - ]; 742 - 743 - assert!(should_promote_h2_when_no_h1(&toc)); 744 - assert_eq!(toc_display_level(2, false, true), 1); 745 - assert_eq!(toc_display_level(3, false, true), 2); 746 - let normalized = normalize_toc(toc); 747 - assert_eq!(normalized.len(), 2); 748 - assert_eq!(normalized[0].level, 2); 749 - assert_eq!(normalized[1].level, 3); 750 - } 751 - 752 - #[test] 753 - fn parse_theme_preset_supports_ocean_and_forest() { 754 - assert_eq!(parse_theme_preset("arctic"), Some(ThemePreset::Arctic)); 755 - assert_eq!(parse_theme_preset("ocean"), Some(ThemePreset::OceanDark)); 756 - assert_eq!(parse_theme_preset("forest"), Some(ThemePreset::Forest)); 757 - assert_eq!( 758 - parse_theme_preset("solarized-dark"), 759 - Some(ThemePreset::SolarizedDark) 760 - ); 761 - assert_eq!(parse_theme_preset("nope"), None); 762 - } 763 - 764 - #[test] 765 - fn resolve_syntax_supports_common_language_aliases() { 766 - let ss = SyntaxSet::load_defaults_newlines(); 767 - 768 - assert_eq!( 769 - resolve_syntax("py", &ss).name, 770 - resolve_syntax("python", &ss).name 771 - ); 772 - assert_eq!( 773 - resolve_syntax("cpp", &ss).name, 774 - resolve_syntax("c++", &ss).name 775 - ); 776 - assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 777 - assert_eq!( 778 - resolve_syntax("ps1", &ss).name, 779 - resolve_syntax("powershell", &ss).name 780 - ); 781 - } 782 - 783 - #[test] 784 - fn theme_presets_are_in_alphabetical_order() { 785 - let labels: Vec<_> = THEME_PRESETS 786 - .iter() 787 - .map(|preset| theme_preset_label(*preset)) 788 - .collect(); 789 - let mut sorted = labels.clone(); 790 - sorted.sort(); 791 - assert_eq!(labels, sorted); 792 - } 793 - 794 - #[test] 795 - fn theme_picker_restores_original_preset_on_escape() { 796 - let _guard = lock_theme_test_state(); 797 - let (ss, theme) = test_assets(); 798 - let ts = ThemeSet::load_defaults(); 799 - let (lines, toc) = parse_markdown("# Demo\n", &ss, &theme); 800 - let mut app = App::new_with_source( 801 - lines, 802 - toc, 803 - AppConfig { 804 - filename: "stdin".to_string(), 805 - source: "# Demo\n".to_string(), 806 - debug_input: false, 807 - watch: false, 808 - filepath: None, 809 - last_file_state: None, 810 - }, 811 - ); 812 - 813 - let original = current_theme_preset(); 814 - set_theme_preset(ThemePreset::OceanDark); 815 - app.open_theme_picker(); 816 - assert!(app.set_theme_picker_index(theme_preset_index(ThemePreset::Forest))); 817 - app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 818 - 819 - assert_eq!(current_theme_preset(), ThemePreset::Forest); 820 - 821 - app.restore_theme_picker_preview(&ss, &ts); 822 - 823 - assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 824 - assert!(!app.is_theme_picker_open()); 825 - assert_eq!(app.theme_picker_original(), None); 826 - set_theme_preset(original); 827 - } 828 - 829 - #[test] 830 - fn theme_picker_caches_previewed_themes_for_reuse() { 831 - let _guard = lock_theme_test_state(); 832 - let (ss, theme) = test_assets(); 833 - let ts = ThemeSet::load_defaults(); 834 - let (lines, toc) = parse_markdown("# Demo\n\n```rs\nfn main() {}\n```\n", &ss, &theme); 835 - let mut app = App::new_with_source( 836 - lines, 837 - toc, 838 - AppConfig { 839 - filename: "stdin".to_string(), 840 - source: "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 841 - debug_input: false, 842 - watch: false, 843 - filepath: None, 844 - last_file_state: None, 845 - }, 846 - ); 847 - 848 - let original = current_theme_preset(); 849 - set_theme_preset(ThemePreset::OceanDark); 850 - app.open_theme_picker(); 851 - app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 852 - 853 - assert!(app.has_cached_theme_preview(ThemePreset::Forest)); 854 - assert_eq!(current_theme_preset(), ThemePreset::Forest); 855 - 856 - app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 857 - assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 858 - assert!(app.has_cached_theme_preview(ThemePreset::OceanDark)); 859 - set_theme_preset(original); 860 217 } 861 218 862 219 #[test] ··· 1783 1140 parse_markdown_with_width(source, &ss, &theme, 20).0.len() 1784 1141 ); 1785 1142 } 1786 - 1787 - #[test] 1788 - fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { 1789 - let (ss, theme) = test_assets(); 1790 - let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; 1791 - let (lines, _) = parse_markdown_with_width(source, &ss, &theme, 22); 1792 - 1793 - let target = lines 1794 - .iter() 1795 - .find(|line| line_plain_text(line).contains("leaf --theme")) 1796 - .expect("expected wrapped inline-code line"); 1797 - 1798 - assert!( 1799 - target 1800 - .spans 1801 - .iter() 1802 - .any(|span| span.style.bg.is_some() && span.content.starts_with(' ')), 1803 - "expected a background-styled span with left padding" 1804 - ); 1805 - } 1806 - 1807 - #[test] 1808 - fn code_block_inside_list_item_is_indented_and_has_no_blank_gap_before() { 1809 - let (ss, theme) = test_assets(); 1810 - let md = "To put a code block within a list item, the code block needs\nto be indented *twice* -- 8 spaces or two tabs:\n\n* A list item with a code block:\n\n <code goes here>\n"; 1811 - let (lines, _) = parse_markdown(md, &ss, &theme); 1812 - let rendered = rendered_non_empty_lines(&lines); 1813 - 1814 - let item_idx = rendered 1815 - .iter() 1816 - .position(|line| line.contains("A list item with a code block:")) 1817 - .expect("missing list item line"); 1818 - let header_idx = rendered 1819 - .iter() 1820 - .position(|line| line.contains("┌─ text")) 1821 - .expect("missing code block header"); 1822 - let code_idx = rendered 1823 - .iter() 1824 - .position(|line| line.contains("<code goes here>")) 1825 - .expect("missing code line"); 1826 - 1827 - assert_eq!( 1828 - header_idx, 1829 - item_idx + 1, 1830 - "expected no blank gap before code block" 1831 - ); 1832 - assert!(rendered[header_idx].starts_with(" ")); 1833 - assert!(rendered[code_idx].starts_with(" ")); 1834 - }
+364
src/tests/markdown.rs
··· 1 + use super::{rendered_non_empty_lines, test_assets}; 2 + use crate::markdown::{parse_markdown, parse_markdown_with_width, resolve_syntax}; 3 + use crate::*; 4 + use syntect::parsing::SyntaxSet; 5 + 6 + #[test] 7 + fn h1_headings_render_double_rule_without_bottom_spacing() { 8 + let (ss, theme) = test_assets(); 9 + let (lines, _) = parse_markdown("# 東京\n", &ss, &theme); 10 + let rendered = rendered_non_empty_lines(&lines); 11 + 12 + assert_eq!(rendered[0], "東京"); 13 + assert_eq!(rendered[1], "═".repeat(display_width("東京"))); 14 + } 15 + 16 + #[test] 17 + fn loose_list_items_keep_their_markers() { 18 + let (ss, theme) = test_assets(); 19 + let (lines, _) = parse_markdown("- first\n\n- second\n", &ss, &theme); 20 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 21 + 22 + assert!(rendered.iter().any(|line| line.contains("• first"))); 23 + assert!(rendered.iter().any(|line| line.contains("• second"))); 24 + } 25 + 26 + #[test] 27 + fn ordered_lists_render_numeric_markers() { 28 + let (ss, theme) = test_assets(); 29 + let (lines, _) = parse_markdown("3. third\n4. fourth\n", &ss, &theme); 30 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 31 + 32 + assert!(rendered.iter().any(|line| line.contains("3. third"))); 33 + assert!(rendered.iter().any(|line| line.contains("4. fourth"))); 34 + } 35 + 36 + #[test] 37 + fn multiline_list_items_keep_marker_only_on_first_line() { 38 + let (ss, theme) = test_assets(); 39 + let (lines, _) = parse_markdown("- first line\n second line\n", &ss, &theme); 40 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 41 + 42 + let first = rendered 43 + .iter() 44 + .find(|line| line.contains("first line")) 45 + .unwrap(); 46 + let second = rendered 47 + .iter() 48 + .find(|line| line.contains("second line")) 49 + .unwrap(); 50 + 51 + assert!(first.contains("• first line")); 52 + assert!(!second.contains('•')); 53 + assert!(second.starts_with(" ")); 54 + } 55 + 56 + #[test] 57 + fn ordered_lists_preserve_non_default_start_numbers() { 58 + let (ss, theme) = test_assets(); 59 + let (lines, _) = parse_markdown("7. seven\n8. eight\n", &ss, &theme); 60 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 61 + 62 + assert!(rendered.iter().any(|line| line.contains("7. seven"))); 63 + assert!(rendered.iter().any(|line| line.contains("8. eight"))); 64 + } 65 + 66 + #[test] 67 + fn loose_list_items_render_expected_lines() { 68 + let (ss, theme) = test_assets(); 69 + let src = "- first loose item\n\n- second loose item after a blank line\n\n- third loose item\n\n continuation paragraph\n"; 70 + let (lines, _) = parse_markdown(src, &ss, &theme); 71 + let rendered = rendered_non_empty_lines(&lines); 72 + 73 + assert_eq!( 74 + rendered, 75 + vec![ 76 + "• first loose item", 77 + "• second loose item after a blank line", 78 + "• third loose item", 79 + " continuation paragraph", 80 + ] 81 + ); 82 + } 83 + 84 + #[test] 85 + fn ordered_loose_lists_render_expected_lines() { 86 + let (ss, theme) = test_assets(); 87 + let src = "7. seventh item\n\n8. eighth item\n\n continuation paragraph\n"; 88 + let (lines, _) = parse_markdown(src, &ss, &theme); 89 + let rendered = rendered_non_empty_lines(&lines); 90 + 91 + assert_eq!( 92 + rendered, 93 + vec![ 94 + "7. seventh item", 95 + "8. eighth item", 96 + " continuation paragraph", 97 + ] 98 + ); 99 + } 100 + 101 + #[test] 102 + fn ordered_lists_render_expected_lines() { 103 + let (ss, theme) = test_assets(); 104 + let (lines, _) = parse_markdown("3. third item\n4. fourth item\n", &ss, &theme); 105 + let rendered = rendered_non_empty_lines(&lines); 106 + 107 + assert_eq!(rendered, vec!["3. third item", "4. fourth item"]); 108 + } 109 + 110 + #[test] 111 + fn paragraph_and_following_list_have_no_blank_gap() { 112 + let (ss, theme) = test_assets(); 113 + let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 114 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 115 + let intro_idx = rendered 116 + .iter() 117 + .position(|line| line == "Intro paragraph") 118 + .unwrap(); 119 + 120 + assert_eq!(rendered[intro_idx + 1], "• first"); 121 + } 122 + 123 + #[test] 124 + fn wrapped_list_items_align_continuation_under_text() { 125 + let (ss, theme) = test_assets(); 126 + let src = "- First item with enough text to wrap when the terminal is narrow and show continuation alignment.\n8. Eighth item with enough text to wrap and keep numeric alignment readable.\n"; 127 + let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 36); 128 + let rendered = rendered_non_empty_lines(&lines); 129 + 130 + assert!(rendered.iter().any(|line| line.starts_with("• First item"))); 131 + assert!(rendered 132 + .iter() 133 + .any(|line| line.starts_with(" ") && line.contains("terminal is narrow"))); 134 + assert!(rendered 135 + .iter() 136 + .any(|line| line.starts_with("8. Eighth item"))); 137 + assert!(rendered 138 + .iter() 139 + .any(|line| line.starts_with(" ") && !line.starts_with("8. "))); 140 + } 141 + 142 + #[test] 143 + fn paragraph_and_following_code_block_have_no_blank_gap() { 144 + let (ss, theme) = test_assets(); 145 + let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 146 + let (lines, _) = parse_markdown(src, &ss, &theme); 147 + let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 148 + let intro_idx = rendered 149 + .iter() 150 + .position(|line| line == "Intro paragraph") 151 + .unwrap(); 152 + 153 + assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 154 + } 155 + 156 + #[test] 157 + fn nested_blockquotes_keep_quote_prefix_after_inner_quote_ends() { 158 + let (ss, theme) = test_assets(); 159 + let src = "> outer\n> > inner\n> outer again\n"; 160 + let (lines, _) = parse_markdown(src, &ss, &theme); 161 + let rendered = rendered_non_empty_lines(&lines); 162 + 163 + assert!(rendered.iter().any(|line| line == "▏ outer")); 164 + assert!(rendered.iter().any(|line| line == "▏ inner")); 165 + assert!(rendered.iter().any(|line| line == "▏ outer again")); 166 + } 167 + 168 + #[test] 169 + fn long_blockquotes_wrap_into_multiple_prefixed_lines() { 170 + let (ss, theme) = test_assets(); 171 + let src = "> This is a long blockquote line that should wrap into multiple quoted lines at narrow widths.\n"; 172 + let (lines, _) = parse_markdown_with_width(src, &ss, &theme, 28); 173 + let rendered = rendered_non_empty_lines(&lines); 174 + let quoted: Vec<_> = rendered 175 + .into_iter() 176 + .filter(|line| line.starts_with('▏')) 177 + .collect(); 178 + 179 + assert!(quoted.len() >= 2); 180 + assert!(quoted.iter().all(|line| line.starts_with("▏ "))); 181 + } 182 + 183 + #[test] 184 + fn toc_only_includes_first_two_heading_levels() { 185 + let (ss, theme) = test_assets(); 186 + let (_, toc) = parse_markdown("# One\n## Two\n### Three\n#### Four\n", &ss, &theme); 187 + 188 + assert_eq!(toc.len(), 3); 189 + assert_eq!(toc[0].level, 1); 190 + assert_eq!(toc[1].level, 2); 191 + assert_eq!(toc[2].level, 3); 192 + } 193 + 194 + #[test] 195 + fn frontmatter_is_ignored_in_preview_and_toc() { 196 + let (ss, theme) = test_assets(); 197 + let src = "---\ntitle: Demo\nowner: me\n---\n# Visible\nBody\n"; 198 + let (lines, toc) = parse_markdown(src, &ss, &theme); 199 + let rendered = rendered_non_empty_lines(&lines); 200 + 201 + assert!(!rendered.iter().any(|line| line.contains("title: Demo"))); 202 + assert!(rendered.iter().any(|line| line.contains("Visible"))); 203 + assert_eq!(toc.len(), 1); 204 + assert_eq!(toc[0].title, "Visible"); 205 + } 206 + 207 + #[test] 208 + fn h2_headings_are_underlined_and_compact() { 209 + let (ss, theme) = test_assets(); 210 + let (lines, _) = parse_markdown_with_width("Intro\n\n## Section\nBody\n", &ss, &theme, 40); 211 + let rendered = rendered_non_empty_lines(&lines); 212 + 213 + assert!(rendered.iter().any(|line| line.contains("Section"))); 214 + assert!(rendered.iter().any(|line| line.contains("────"))); 215 + } 216 + 217 + #[test] 218 + fn rules_use_render_width_without_extra_blank_after() { 219 + let (ss, theme) = test_assets(); 220 + let (lines, _) = parse_markdown_with_width("Alpha\n\n---\nBeta\n", &ss, &theme, 24); 221 + let rendered = rendered_non_empty_lines(&lines); 222 + let rule = rendered 223 + .iter() 224 + .find(|line| line.trim_start().starts_with('─')) 225 + .unwrap(); 226 + 227 + assert_eq!(display_width(rule.trim_start()), 24); 228 + let rule_idx = rendered.iter().position(|line| line == rule).unwrap(); 229 + assert_eq!(rendered[rule_idx + 1], "Beta"); 230 + } 231 + 232 + #[test] 233 + fn toc_hides_single_h1_when_h2_entries_exist() { 234 + let toc = vec![ 235 + TocEntry { 236 + level: 1, 237 + title: "Doc Title".to_string(), 238 + line: 0, 239 + }, 240 + TocEntry { 241 + level: 2, 242 + title: "Install".to_string(), 243 + line: 10, 244 + }, 245 + ]; 246 + 247 + assert!(should_hide_single_h1(&toc)); 248 + assert_eq!(toc_display_level(2, true, false), 1); 249 + assert_eq!(toc_display_level(3, true, false), 2); 250 + } 251 + 252 + #[test] 253 + fn toc_keeps_single_h1_when_no_h2_entries_exist() { 254 + let toc = vec![TocEntry { 255 + level: 1, 256 + title: "Doc Title".to_string(), 257 + line: 0, 258 + }]; 259 + 260 + assert!(!should_hide_single_h1(&toc)); 261 + } 262 + 263 + #[test] 264 + fn toc_promotes_h2_when_document_has_no_h1() { 265 + let toc = vec![ 266 + TocEntry { 267 + level: 2, 268 + title: "Build & install".to_string(), 269 + line: 0, 270 + }, 271 + TocEntry { 272 + level: 3, 273 + title: "Android".to_string(), 274 + line: 4, 275 + }, 276 + ]; 277 + 278 + assert!(should_promote_h2_when_no_h1(&toc)); 279 + assert_eq!(toc_display_level(2, false, true), 1); 280 + assert_eq!(toc_display_level(3, false, true), 2); 281 + let normalized = normalize_toc(toc); 282 + assert_eq!(normalized.len(), 2); 283 + assert_eq!(normalized[0].level, 2); 284 + assert_eq!(normalized[1].level, 3); 285 + } 286 + 287 + #[test] 288 + fn resolve_syntax_supports_common_language_aliases() { 289 + let ss = SyntaxSet::load_defaults_newlines(); 290 + 291 + assert_eq!( 292 + resolve_syntax("py", &ss).name, 293 + resolve_syntax("python", &ss).name 294 + ); 295 + assert_eq!( 296 + resolve_syntax("cpp", &ss).name, 297 + resolve_syntax("c++", &ss).name 298 + ); 299 + assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 300 + assert_eq!( 301 + resolve_syntax("ps1", &ss).name, 302 + resolve_syntax("powershell", &ss).name 303 + ); 304 + } 305 + 306 + #[test] 307 + fn narrow_tables_fit_render_width_and_wrap_cells() { 308 + let (ss, theme) = test_assets(); 309 + let md = "| Column | Description | Value |\n| --- | --- | ---: |\n| Width | Terminal-dependent layout behavior | 80 |\n"; 310 + let (lines, _) = parse_markdown_with_width(md, &ss, &theme, 36); 311 + let rendered = rendered_non_empty_lines(&lines); 312 + 313 + assert!(rendered.len() >= 6); 314 + assert!(rendered.iter().all(|line| display_width(line) <= 36)); 315 + } 316 + 317 + #[test] 318 + fn wrapped_list_inline_code_keeps_left_padding_in_rendered_line() { 319 + let (ss, theme) = test_assets(); 320 + let source = "- `leaf --theme ocean README.md` exercises wrapping inside a list item.\n"; 321 + let (lines, _) = parse_markdown_with_width(source, &ss, &theme, 22); 322 + 323 + let target = lines 324 + .iter() 325 + .find(|line| line_plain_text(line).contains("leaf --theme")) 326 + .expect("expected wrapped inline-code line"); 327 + 328 + assert!( 329 + target 330 + .spans 331 + .iter() 332 + .any(|span| span.style.bg.is_some() && span.content.starts_with(' ')), 333 + "expected a background-styled span with left padding" 334 + ); 335 + } 336 + 337 + #[test] 338 + fn code_block_inside_list_item_is_indented_and_has_no_blank_gap_before() { 339 + let (ss, theme) = test_assets(); 340 + let md = "To put a code block within a list item, the code block needs\nto be indented *twice* -- 8 spaces or two tabs:\n\n* A list item with a code block:\n\n <code goes here>\n"; 341 + let (lines, _) = parse_markdown(md, &ss, &theme); 342 + let rendered = rendered_non_empty_lines(&lines); 343 + 344 + let item_idx = rendered 345 + .iter() 346 + .position(|line| line.contains("A list item with a code block:")) 347 + .expect("missing list item line"); 348 + let header_idx = rendered 349 + .iter() 350 + .position(|line| line.contains("┌─ text")) 351 + .expect("missing code block header"); 352 + let code_idx = rendered 353 + .iter() 354 + .position(|line| line.contains("<code goes here>")) 355 + .expect("missing code line"); 356 + 357 + assert_eq!( 358 + header_idx, 359 + item_idx + 1, 360 + "expected no blank gap before code block" 361 + ); 362 + assert!(rendered[header_idx].starts_with(" ")); 363 + assert!(rendered[code_idx].starts_with(" ")); 364 + }
+79
src/tests/mod.rs
··· 1 + use crate::*; 2 + use ratatui::backend::TestBackend; 3 + use ratatui::{text::Line, widgets::Paragraph, Terminal}; 4 + use std::{ 5 + path::PathBuf, 6 + sync::{Mutex, MutexGuard}, 7 + time::{SystemTime, UNIX_EPOCH}, 8 + }; 9 + use syntect::{ 10 + highlighting::{Theme, ThemeSet}, 11 + parsing::SyntaxSet, 12 + }; 13 + 14 + mod app; 15 + mod markdown; 16 + mod render; 17 + mod theme; 18 + mod update; 19 + 20 + pub(super) static THEME_TEST_MUTEX: Mutex<()> = Mutex::new(()); 21 + 22 + pub(super) fn test_assets() -> (SyntaxSet, Theme) { 23 + let ss = SyntaxSet::load_defaults_newlines(); 24 + let ts = ThemeSet::load_defaults(); 25 + let theme = ts.themes["base16-ocean.dark"].clone(); 26 + (ss, theme) 27 + } 28 + 29 + pub(super) fn render_buffer(lines: &[Line<'static>]) -> ratatui::buffer::Buffer { 30 + let width = lines 31 + .iter() 32 + .map(|line| line.width()) 33 + .max() 34 + .unwrap_or(1) 35 + .max(1) as u16; 36 + let height = lines.len().max(1) as u16; 37 + let backend = TestBackend::new(width, height); 38 + let mut terminal = Terminal::new(backend).unwrap(); 39 + terminal 40 + .draw(|f| { 41 + f.render_widget(Paragraph::new(lines.to_vec()), f.area()); 42 + }) 43 + .unwrap(); 44 + terminal.backend().buffer().clone() 45 + } 46 + 47 + pub(super) fn find_symbol(buffer: &ratatui::buffer::Buffer, symbol: &str) -> Option<(u16, u16)> { 48 + for y in 0..buffer.area.height { 49 + for x in 0..buffer.area.width { 50 + if buffer 51 + .cell((x, y)) 52 + .is_some_and(|cell| cell.symbol() == symbol) 53 + { 54 + return Some((x, y)); 55 + } 56 + } 57 + } 58 + None 59 + } 60 + 61 + pub(super) fn rendered_non_empty_lines(lines: &[Line<'static>]) -> Vec<String> { 62 + lines 63 + .iter() 64 + .map(line_plain_text) 65 + .filter(|line| !line.is_empty()) 66 + .collect() 67 + } 68 + 69 + pub(super) fn lock_theme_test_state() -> MutexGuard<'static, ()> { 70 + THEME_TEST_MUTEX.lock().unwrap() 71 + } 72 + 73 + pub(super) fn unique_temp_dir(prefix: &str) -> PathBuf { 74 + let unique = SystemTime::now() 75 + .duration_since(UNIX_EPOCH) 76 + .unwrap() 77 + .as_nanos(); 78 + std::env::temp_dir().join(format!("{prefix}-{unique}")) 79 + }
+59
src/tests/render.rs
··· 1 + use super::{find_symbol, render_buffer, test_assets}; 2 + use crate::markdown::parse_markdown; 3 + 4 + #[test] 5 + fn code_block_box_renders_right_border_in_one_column() { 6 + let (ss, theme) = test_assets(); 7 + let md = "```ts\nconst city = \"東京\";\n\tconsole.log(city)\n```"; 8 + let (lines, _) = parse_markdown(md, &ss, &theme); 9 + let buffer = render_buffer(&lines); 10 + 11 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 12 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 13 + 14 + for y in start_y + 1..end_y { 15 + assert_eq!( 16 + buffer.cell((right_x, y)).unwrap().symbol(), 17 + "│", 18 + "missing code block right border at row {y}" 19 + ); 20 + } 21 + } 22 + 23 + #[test] 24 + fn table_render_right_border_stays_aligned() { 25 + let (ss, theme) = test_assets(); 26 + let md = "| Name | Value |\n| --- | --- |\n| 東京 | 12 |\n| tab\tcell | ok |"; 27 + let (lines, _) = parse_markdown(md, &ss, &theme); 28 + let buffer = render_buffer(&lines); 29 + 30 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 31 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 32 + 33 + for y in start_y + 1..end_y { 34 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 35 + assert!( 36 + matches!(symbol, "│" | "┤" | "╡"), 37 + "unexpected table edge symbol {symbol:?} at row {y}" 38 + ); 39 + } 40 + } 41 + 42 + #[test] 43 + fn table_render_right_border_stays_aligned_with_emoji_cells() { 44 + let (ss, theme) = test_assets(); 45 + let md = "| Critère | Note |\n| --- | --- |\n| Tests | ✅ Bonne couverture |\n| Sécurité | ⚠ Quelques points |\n"; 46 + let (lines, _) = parse_markdown(md, &ss, &theme); 47 + let buffer = render_buffer(&lines); 48 + 49 + let (right_x, start_y) = find_symbol(&buffer, "┐").unwrap(); 50 + let (_, end_y) = find_symbol(&buffer, "┘").unwrap(); 51 + 52 + for y in start_y + 1..end_y { 53 + let symbol = buffer.cell((right_x, y)).unwrap().symbol(); 54 + assert!( 55 + matches!(symbol, "│" | "┤" | "╡"), 56 + "unexpected emoji-table edge symbol {symbol:?} at row {y}" 57 + ); 58 + } 59 + }
+97
src/tests/theme.rs
··· 1 + use super::{lock_theme_test_state, test_assets}; 2 + use crate::app::{App, AppConfig}; 3 + use crate::markdown::parse_markdown; 4 + use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 5 + use crate::*; 6 + use syntect::highlighting::ThemeSet; 7 + 8 + #[test] 9 + fn parse_theme_preset_supports_ocean_and_forest() { 10 + assert_eq!(parse_theme_preset("arctic"), Some(ThemePreset::Arctic)); 11 + assert_eq!(parse_theme_preset("ocean"), Some(ThemePreset::OceanDark)); 12 + assert_eq!(parse_theme_preset("forest"), Some(ThemePreset::Forest)); 13 + assert_eq!( 14 + parse_theme_preset("solarized-dark"), 15 + Some(ThemePreset::SolarizedDark) 16 + ); 17 + assert_eq!(parse_theme_preset("nope"), None); 18 + } 19 + 20 + #[test] 21 + fn theme_presets_are_in_alphabetical_order() { 22 + let labels: Vec<_> = THEME_PRESETS 23 + .iter() 24 + .map(|preset| theme_preset_label(*preset)) 25 + .collect(); 26 + let mut sorted = labels.clone(); 27 + sorted.sort(); 28 + assert_eq!(labels, sorted); 29 + } 30 + 31 + #[test] 32 + fn theme_picker_restores_original_preset_on_escape() { 33 + let _guard = lock_theme_test_state(); 34 + let (ss, theme) = test_assets(); 35 + let ts = ThemeSet::load_defaults(); 36 + let (lines, toc) = parse_markdown("# Demo\n", &ss, &theme); 37 + let mut app = App::new_with_source( 38 + lines, 39 + toc, 40 + AppConfig { 41 + filename: "stdin".to_string(), 42 + source: "# Demo\n".to_string(), 43 + debug_input: false, 44 + watch: false, 45 + filepath: None, 46 + last_file_state: None, 47 + }, 48 + ); 49 + 50 + let original = current_theme_preset(); 51 + set_theme_preset(ThemePreset::OceanDark); 52 + app.open_theme_picker(); 53 + assert!(app.set_theme_picker_index(theme_preset_index(ThemePreset::Forest))); 54 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 55 + 56 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 57 + 58 + app.restore_theme_picker_preview(&ss, &ts); 59 + 60 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 61 + assert!(!app.is_theme_picker_open()); 62 + assert_eq!(app.theme_picker_original(), None); 63 + set_theme_preset(original); 64 + } 65 + 66 + #[test] 67 + fn theme_picker_caches_previewed_themes_for_reuse() { 68 + let _guard = lock_theme_test_state(); 69 + let (ss, theme) = test_assets(); 70 + let ts = ThemeSet::load_defaults(); 71 + let (lines, toc) = parse_markdown("# Demo\n\n```rs\nfn main() {}\n```\n", &ss, &theme); 72 + let mut app = App::new_with_source( 73 + lines, 74 + toc, 75 + AppConfig { 76 + filename: "stdin".to_string(), 77 + source: "# Demo\n\n```rs\nfn main() {}\n```\n".to_string(), 78 + debug_input: false, 79 + watch: false, 80 + filepath: None, 81 + last_file_state: None, 82 + }, 83 + ); 84 + 85 + let original = current_theme_preset(); 86 + set_theme_preset(ThemePreset::OceanDark); 87 + app.open_theme_picker(); 88 + app.preview_theme_preset(ThemePreset::Forest, &ss, &ts); 89 + 90 + assert!(app.has_cached_theme_preview(ThemePreset::Forest)); 91 + assert_eq!(current_theme_preset(), ThemePreset::Forest); 92 + 93 + app.preview_theme_preset(ThemePreset::OceanDark, &ss, &ts); 94 + assert_eq!(current_theme_preset(), ThemePreset::OceanDark); 95 + assert!(app.has_cached_theme_preview(ThemePreset::OceanDark)); 96 + set_theme_preset(original); 97 + }
+116
src/tests/update.rs
··· 1 + use crate::update::TestAsset; 2 + use crate::*; 3 + 4 + #[test] 5 + fn asset_name_matches_supported_release_targets() { 6 + assert_eq!( 7 + asset_name_for_target("macos", "x86_64"), 8 + Some("leaf-macos-x86_64") 9 + ); 10 + assert_eq!( 11 + asset_name_for_target("macos", "aarch64"), 12 + Some("leaf-macos-arm64") 13 + ); 14 + assert_eq!( 15 + asset_name_for_target("linux", "x86_64"), 16 + Some("leaf-linux-x86_64") 17 + ); 18 + assert_eq!( 19 + asset_name_for_target("linux", "aarch64"), 20 + Some("leaf-linux-arm64") 21 + ); 22 + assert_eq!( 23 + asset_name_for_target("android", "aarch64"), 24 + Some("leaf-android-arm64") 25 + ); 26 + assert_eq!( 27 + asset_name_for_target("windows", "x86_64"), 28 + Some("leaf-windows-x86_64.exe") 29 + ); 30 + assert_eq!(asset_name_for_target("linux", "arm"), None); 31 + } 32 + 33 + #[test] 34 + fn newer_version_comparison_accepts_optional_v_prefix() { 35 + assert!(is_newer_version("1.4.2", "v1.4.3").unwrap()); 36 + assert!(!is_newer_version("1.4.2", "1.4.2").unwrap()); 37 + assert!(!is_newer_version("1.4.2", "1.4.1").unwrap()); 38 + } 39 + 40 + #[test] 41 + fn expected_asset_download_url_selects_matching_asset() { 42 + let assets = vec![ 43 + TestAsset { 44 + name: "leaf-linux-x86_64", 45 + download_url: "https://example.test/linux", 46 + }, 47 + TestAsset { 48 + name: "leaf-windows-x86_64.exe", 49 + download_url: "https://example.test/windows", 50 + }, 51 + ]; 52 + 53 + let url = expected_asset_download_url("1.4.3", &assets, "leaf-linux-x86_64").unwrap(); 54 + assert_eq!(url, "https://example.test/linux"); 55 + } 56 + 57 + #[test] 58 + fn expected_asset_download_url_errors_when_asset_is_missing() { 59 + let assets = vec![TestAsset { 60 + name: "leaf-linux-x86_64", 61 + download_url: "https://example.test/linux", 62 + }]; 63 + 64 + let err = expected_asset_download_url("1.4.3", &assets, "leaf-macos-arm64").unwrap_err(); 65 + assert!(err.to_string().contains("does not contain asset")); 66 + } 67 + 68 + #[test] 69 + fn validate_download_size_accepts_matching_non_zero_sizes() { 70 + assert!(validate_download_size(Some(42), 42).is_ok()); 71 + assert!(validate_download_size(None, 42).is_ok()); 72 + } 73 + 74 + #[test] 75 + fn validate_download_size_rejects_zero_or_mismatched_sizes() { 76 + let empty_err = validate_download_size(None, 0).unwrap_err(); 77 + assert!(empty_err.to_string().contains("is empty")); 78 + 79 + let mismatch_err = validate_download_size(Some(42), 41).unwrap_err(); 80 + assert!(mismatch_err.to_string().contains("size mismatch")); 81 + } 82 + 83 + #[test] 84 + fn find_expected_checksum_extracts_matching_asset_checksum() { 85 + let checksums = "\ 86 + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa leaf-linux-x86_64 87 + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb leaf-windows-x86_64.exe 88 + "; 89 + 90 + let checksum = find_expected_checksum(checksums, "leaf-windows-x86_64.exe").unwrap(); 91 + assert_eq!( 92 + checksum, 93 + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 94 + ); 95 + } 96 + 97 + #[test] 98 + fn find_expected_checksum_rejects_missing_or_invalid_entries() { 99 + let missing = 100 + find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64").unwrap_err(); 101 + assert!(missing.to_string().contains("does not contain")); 102 + 103 + let invalid = 104 + find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64").unwrap_err(); 105 + assert!(invalid 106 + .to_string() 107 + .contains("Invalid SHA256 checksum format")); 108 + } 109 + 110 + #[test] 111 + fn validate_sha256_hex_accepts_expected_format() { 112 + assert!(validate_sha256_hex( 113 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 114 + ) 115 + .is_ok()); 116 + }