use justerm_core::{Engine, decode, encode};
const OPEN: &[u8] = b"\x1b]8;;https://example.com\x07";
const CLOSE: &[u8] = b"\x1b]8;;\x07";
#[test]
fn cells_under_open_link_carry_the_uri() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"ab");
t.feed(CLOSE);
assert_eq!(t.grid().cell(0, 0).c(), 'a');
assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"'a' should carry the link",
);
assert_eq!(
t.link_at(0, 1).as_ref().map(|h| h.uri()),
Some("https://example.com")
);
}
#[test]
fn text_before_open_and_after_close_has_no_link() {
let mut t = Engine::new(80, 24);
t.feed(b"x");
t.feed(OPEN);
t.feed(b"y");
t.feed(CLOSE);
t.feed(b"z");
assert_eq!(t.grid().cell(0, 0).c(), 'x');
assert_eq!(t.link_at(0, 0), None);
assert!(t.link_at(0, 1).is_some()); assert_eq!(t.grid().cell(0, 2).c(), 'z');
assert_eq!(t.link_at(0, 2), None);
}
#[test]
fn sgr_reset_does_not_close_a_hyperlink() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"a\x1b[0mb"); assert!(t.link_at(0, 0).is_some());
assert_eq!(t.link_at(0, 1), t.link_at(0, 0));
}
#[test]
fn wide_glyph_lead_and_spacer_share_the_link() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed("世".as_bytes());
t.feed(CLOSE);
assert_eq!(t.grid().cell(0, 0).c(), '世');
assert!(t.link_at(0, 0).is_some());
assert_eq!(t.link_at(0, 1), t.link_at(0, 0)); }
#[test]
fn link_survives_scroll_into_scrollback() {
let mut t = Engine::new(80, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.feed(b"\r\nsecond\r\n"); assert!(t.scrollback_len() >= 1);
t.scroll_up(1);
assert_eq!(t.viewport_line(0)[0].c(), 'L');
assert_eq!(
t.viewport_link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link survives scroll into scrollback",
);
}
#[test]
fn frame_carries_a_scrolled_back_links_uri() {
let mut t = Engine::new(80, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.feed(b"\r\nsecond\r\n"); t.scroll_up(1);
let frame = t.frame();
assert_eq!(
frame.link_table,
vec!["https://example.com".to_string()],
"the scrolled-back link did not reach the frame's link_table",
);
let span = frame
.spans
.iter()
.find(|s| s.line == 0)
.expect("full frame covers row 0");
let idx = span.links.get(&0).expect("col 0 references the link");
assert_eq!(
frame.link_table[idx.get() as usize - 1],
"https://example.com"
);
}
#[test]
fn plain_output_carries_no_link() {
let mut t = Engine::new(80, 24);
t.feed(b"plain");
assert_eq!(t.link_at(0, 0), None);
}
#[test]
fn link_follows_an_insert_shift() {
let mut t = Engine::new(6, 1);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE); t.feed(b"\x1b[1;1H"); t.feed(b"\x1b[2@"); assert_eq!(t.grid().cell(0, 2).c(), 'L');
assert_eq!(
t.link_at(0, 2).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link followed the insert shift"
);
assert_eq!(t.link_at(0, 0), None, "the opened gap carries no link");
}
#[test]
fn link_follows_a_delete_shift() {
let mut t = Engine::new(6, 1);
t.feed(b"xy");
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE); t.feed(b"\x1b[1;1H"); t.feed(b"\x1b[2P"); assert_eq!(t.grid().cell(0, 0).c(), 'L');
assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link followed the delete shift"
);
}
#[test]
fn link_survives_resize_reflow() {
let mut t = Engine::new(5, 2);
t.feed(OPEN);
t.feed(b"L");
t.feed(CLOSE);
t.resize(3, 2); assert_eq!(
t.link_at(0, 0).as_ref().map(|h| h.uri()),
Some("https://example.com"),
"link survives reflow"
);
}
#[test]
fn hyperlink_round_trips_through_serialization() {
let mut t = Engine::new(80, 24);
t.feed(OPEN);
t.feed(b"hi");
t.feed(CLOSE);
let frame = t.frame();
assert_eq!(frame.link_table, vec!["https://example.com".to_string()]);
let decoded = decode(&encode(&frame)).expect("decode");
assert_eq!(decoded, frame);
let span = &decoded.spans[0];
let hcol = span
.cells
.iter()
.position(|c| c.c() == 'h')
.expect("'h' present");
let idx = span
.links
.get(&hcol)
.copied()
.expect("decoded span keeps the link");
assert_eq!(
decoded.link_table[idx.get() as usize - 1],
"https://example.com"
);
}
#[test]
fn a_uri_keeps_its_unencoded_semicolons() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/p?a=1;b=2\x07X\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://example.com/p?a=1;b=2"),
"the tail after the first ';' belongs to the URI, not to another parameter",
);
}
#[test]
fn a_semicolon_uri_still_groups_by_id_on_its_whole_value() {
let mut t = Engine::new(60, 4);
t.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07A\x1b]8;;\x07\r\n");
t.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07B\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://x/p?a=1;b=2"),
"the id= parse must not have eaten part of the URI",
);
assert_eq!(
t.frame().link_table.len(),
1,
"same id, same whole URI — one link",
);
let mut u = Engine::new(60, 4);
u.feed(b"\x1b]8;id=q;https://x/p?a=1;b=2\x07A\x1b]8;;\x07\r\n");
u.feed(b"\x1b]8;id=q;https://x/p?a=1;b=9\x07B\x1b]8;;\x07");
assert_eq!(
u.frame().link_table.len(),
2,
"the group key sees the whole URI, so these are two links",
);
}
#[test]
fn an_empty_uri_still_closes_after_the_rejoin() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/a;b\x07X\x1b]8;;\x07Y");
assert!(t.link_at(0, 0).is_some(), "X is inside the link");
assert!(
t.link_at(0, 1).is_none(),
"Y is after the close — a rejoin that produced a non-empty URI would leave it open",
);
}
#[test]
fn a_percent_encoded_semicolon_is_not_decoded() {
let mut t = Engine::new(60, 2);
t.feed(b"\x1b]8;;https://example.com/a%3Bb=c\x07X\x1b]8;;\x07");
assert_eq!(
t.link_at(0, 0).map(|h| h.uri().to_owned()).as_deref(),
Some("https://example.com/a%3Bb=c"),
);
}
#[test]
fn an_id_grouped_link_round_trips_as_one_link_across_two_lines() {
let mut t = Engine::new(20, 4);
t.feed(b"\x1b]8;id=grp;https://example.com/split\x07first\x1b]8;;\x07\r\n");
t.feed(b"\x1b]8;id=grp;https://example.com/split\x07second\x1b]8;;\x07");
let frame = t.frame();
let decoded = decode(&encode(&frame)).expect("decode");
assert_eq!(decoded, frame, "round-trip is lossless");
assert_eq!(
decoded.link_table,
vec!["https://example.com/split".to_string()],
"the grouped link ships once, not once per run",
);
let mut indices: Vec<u32> = decoded
.spans
.iter()
.flat_map(|s| s.links.values().map(|i| i.get()))
.collect();
indices.dedup();
indices.sort_unstable();
indices.dedup();
assert_eq!(
indices,
vec![1],
"every linked cell on both lines carries the same link index",
);
let lines: std::collections::BTreeSet<u16> = decoded
.spans
.iter()
.filter(|s| !s.links.is_empty())
.map(|s| s.line)
.collect();
assert_eq!(lines.len(), 2, "the two runs are on two separate lines");
}