use std::sync::Arc;
use teloxide::types::MessageId;
use crate::channels::telegram::state::{MergedHost, TelegramState};
use crate::channels::telegram::suggest_options::{
PickRewrite, echo_fallback, go_tier_lines, mark_picked_button, pick_rewrite, picked_block,
suggestion_rows_rich_html,
};
const CHOICE: &str = "Update the SKILL.md with the new callback routing";
#[test]
fn the_edited_block_is_not_quoted() {
let block = picked_block(CHOICE, None);
assert!(
!block.starts_with('>'),
"the in-place record must not be quoted: {block}"
);
}
#[test]
fn the_edited_block_marks_the_choice_and_keeps_the_text() {
let block = picked_block(CHOICE, None);
assert!(block.starts_with('\u{25b6}'), "must lead with the marker");
assert!(block.contains(CHOICE), "the chosen text must survive");
}
#[test]
fn the_fallback_stays_quoted() {
let echo = echo_fallback(CHOICE, None);
assert!(
echo.starts_with("> "),
"fallback must remain a quote: {echo}"
);
assert!(echo.contains(CHOICE));
}
#[test]
fn the_two_shapes_differ() {
assert_ne!(picked_block(CHOICE, None), echo_fallback(CHOICE, None));
}
#[test]
fn text_is_passed_through_untouched() {
for text in [
"Run `cargo clippy` and report",
"Compare <old> against <new>",
"Fix the **bold** claim",
"",
] {
assert!(
picked_block(text, None).ends_with(text),
"text was altered: {text}"
);
}
}
#[test]
fn the_chooser_is_named_when_known() {
let block = picked_block(CHOICE, Some("Daniel"));
assert!(block.contains("Daniel"), "chooser missing: {block}");
assert!(block.contains(CHOICE), "the choice must survive: {block}");
}
#[test]
fn an_unknown_chooser_falls_back_to_the_plain_record() {
for chooser in [None, Some(""), Some(" ")] {
let block = picked_block(CHOICE, chooser);
assert!(block.contains(CHOICE), "the choice was lost: {block}");
assert!(
!block.contains("—"),
"an empty name left a dangling separator"
);
}
}
#[test]
fn the_fallback_echo_names_the_chooser_too() {
let echo = echo_fallback(CHOICE, Some("Daniel"));
assert!(echo.starts_with("> "), "fallback must stay quoted: {echo}");
assert!(echo.contains("Daniel"));
assert!(echo.contains(CHOICE));
}
#[test]
fn classic_host_body_keeps_answer_and_pick() {
let rewrite = pick_rewrite(
Some(("<b>the answer</b>", false, None)),
&picked_block(CHOICE, None),
&picked_block(CHOICE, None),
0,
);
let PickRewrite::ClassicHost(body) = rewrite else {
panic!("a classic host must stay classic: {rewrite:?}")
};
assert!(
body.starts_with("<b>the answer</b>"),
"answer html first: {body}"
);
assert!(body.contains(CHOICE), "pick record survives: {body}");
assert!(body.contains('\u{25b6}'), "pick marker survives: {body}");
}
#[test]
fn rich_host_body_keeps_answer_and_pick() {
let rewrite = pick_rewrite(
Some(("<b>the answer</b>", true, None)),
&picked_block(CHOICE, None),
&picked_block(CHOICE, None),
0,
);
let PickRewrite::RichHost(body) = rewrite else {
panic!("a rich host must stay rich: {rewrite:?}")
};
assert!(
body.starts_with("<b>the answer</b>"),
"answer html first: {body}"
);
assert!(body.contains(CHOICE), "pick record survives: {body}");
}
#[test]
fn standalone_body_is_the_pick_record_alone() {
let record = picked_block(CHOICE, None);
assert_eq!(
pick_rewrite(None, &record, &record, 0),
PickRewrite::Standalone(record)
);
}
#[test]
fn the_rich_flag_decides_the_transport_not_the_body() {
let picked = picked_block(CHOICE, None);
let classic = pick_rewrite(Some(("host", false, None)), &picked, &picked, 0);
let rich = pick_rewrite(Some(("host", true, None)), &picked, &picked, 0);
fn body_of(r: &PickRewrite) -> &str {
match r {
PickRewrite::RichHost(b)
| PickRewrite::RichMarkdownHost(b)
| PickRewrite::ClassicHost(b)
| PickRewrite::Standalone(b) => b.as_str(),
}
}
assert_eq!(body_of(&classic), body_of(&rich));
assert_ne!(classic, rich, "the variant must flip with the flag");
}
fn host(mid: i32, rich: bool) -> MergedHost {
MergedHost {
message_id: MessageId(mid),
html: "<p>answer</p><tg-button-row><tg-button>Go</tg-button></tg-button-row>".into(),
rich,
markdown: None,
}
}
async fn register_one(state: &TelegramState, sid: uuid::Uuid, token_tag: u8) -> String {
let token = state
.register_pending_followups(sid, vec![format!("Option {token_tag}")])
.await;
state
.attach_followup_host(&token, host(100 + i32::from(token_tag), true))
.await;
token
}
#[tokio::test]
async fn clear_rescues_host_records_for_stale_taps() {
let state = Arc::new(TelegramState::new());
let sid = uuid::Uuid::new_v4();
let token = register_one(&state, sid, 1).await;
assert!(state.peek_stale_host(&token).await.is_none());
state.clear_pending_followups(sid).await;
let h = state
.peek_stale_host(&token)
.await
.expect("clear must rescue the merged-host record (#59)");
assert!(h.rich);
assert_eq!(h.message_id.0, 101);
}
#[tokio::test]
async fn forget_removes_only_the_stripped_record() {
let state = Arc::new(TelegramState::new());
let sid = uuid::Uuid::new_v4();
let a = register_one(&state, sid, 1).await;
let b = register_one(&state, sid, 2).await;
state.clear_pending_followups(sid).await;
state.forget_stale_host(&a).await;
assert!(state.peek_stale_host(&a).await.is_none());
assert!(
state.peek_stale_host(&b).await.is_some(),
"unrelated records must survive a forget"
);
state.forget_stale_host(&a).await;
}
#[tokio::test]
async fn unmerged_entries_leave_no_stale_record() {
let state = Arc::new(TelegramState::new());
let sid = uuid::Uuid::new_v4();
let _ = state
.register_pending_followups(sid, vec!["Bare option".to_string()])
.await;
state.clear_pending_followups(sid).await;
assert_eq!(state.stale_host_count().await, 0);
}
#[tokio::test]
async fn stale_map_is_bounded_at_the_cap() {
let state = Arc::new(TelegramState::new());
let sid = uuid::Uuid::new_v4();
for i in 0..37 {
let _ = register_one(&state, sid, (i % 250 + 1) as u8).await;
}
state.clear_pending_followups(sid).await;
assert!(
state.stale_host_count().await <= 32,
"stale-host map must be bounded (FIFO eviction)"
);
}
fn shared_row_html() -> String {
suggestion_rows_rich_html(&["Approve".to_string(), "Decline".to_string()], "tok")
}
#[test]
fn picked_button_flips_to_success_check_disabled() {
let marked = mark_picked_button(&shared_row_html(), 0);
let picked_btn = marked.split("<tg-button ").nth(1).unwrap();
let picked_span = &picked_btn[..picked_btn.find("</tg-button>").unwrap()];
assert!(
picked_span.contains("style=\"success\""),
"picked flips to success: {picked_span}"
);
assert!(
picked_span.contains(" disabled"),
"picked disabled: {picked_span}"
);
assert!(
picked_span.ends_with("\u{2713} Approve"),
"check prefix on the picked label: {picked_span}"
);
}
#[test]
fn unpicked_buttons_disabled_drop_style_and_label() {
let marked = mark_picked_button(&shared_row_html(), 0);
let second = marked.split("<tg-button ").nth(2).unwrap();
let span = &second[..second.find("</tg-button>").unwrap()];
assert!(span.contains(" disabled"), "sibling disabled: {span}");
assert!(
!span.contains("style="),
"#71: sibling drops its style (style visually eats disabled): {span}"
);
assert!(!span.contains('\u{2713}'), "no check on siblings: {span}");
assert!(span.ends_with(">Decline"), "label untouched: {span}");
}
#[test]
fn non_followup_markup_passes_through_byte_for_byte() {
let html = "<p>hi</p><tg-button-row><tg-button type=\"url\" data=\"https://x\">x</tg-button></tg-button-row>";
assert_eq!(mark_picked_button(html, 0), html);
}
#[test]
fn html_without_buttons_is_identity() {
let html = "<b>answer</b>\n\n<p>record</p>";
assert_eq!(mark_picked_button(html, 1), html);
}
#[test]
fn tap_redraw_rich_host_body_has_marked_rows_and_record() {
let host = format!("<b>the answer</b>\n{}", shared_row_html());
let rewrite = pick_rewrite(
Some((&host, true, None)),
&picked_block(CHOICE, None),
&picked_block(CHOICE, None),
1,
);
let PickRewrite::RichHost(body) = rewrite else {
panic!("rich host stays rich")
};
assert!(body.contains("style=\"success\""), "picked marked: {body}");
assert!(body.contains(" disabled"), "buttons disabled: {body}");
assert!(
!body.contains("style=\"primary\"\">Approve"),
"the picked label must be check-prefixed"
);
assert!(body.contains(CHOICE), "pick record survives: {body}");
assert!(
body.rfind(CHOICE).unwrap() > body.rfind("</tg-button-row>").unwrap(),
"record rides after the rows (#39)"
);
}
#[test]
fn markdown_host_redraws_in_the_markdown_plane() {
let html = "<p>answer</p>\n<p>para two</p>";
let md = "answer line\n\nplain paragraph";
let rewrite = pick_rewrite(
Some((html, true, Some(md))),
&picked_block(CHOICE, None),
&picked_block(CHOICE, None),
0,
);
let PickRewrite::RichMarkdownHost(body) = rewrite else {
panic!("markdown host must ride the markdown plane: {rewrite:?}")
};
assert!(
body.starts_with("answer line"),
"body built from the MARKDOWN column: {body}"
);
assert!(!body.contains("<p>"), "no html strip-source leaks: {body}");
assert!(body.contains(CHOICE), "pick record survives: {body}");
}
#[test]
fn markdown_host_pick_rows_are_rewritten_not_stripped() {
let rows = shared_row_html();
let md = format!("answer line\n{rows}");
let rewrite = pick_rewrite(
Some(("<p>answer</p>", true, Some(md.as_str()))),
&picked_block(CHOICE, None),
&picked_block(CHOICE, None),
1,
);
let PickRewrite::RichMarkdownHost(body) = rewrite else {
panic!("markdown host must ride the markdown plane: {rewrite:?}")
};
assert!(body.contains("style=\"success\""), "picked marked: {body}");
assert!(body.contains(" disabled"), "buttons disabled: {body}");
assert!(
!body.contains("style=\"primary\"\">Approve"),
"the picked label must be check-prefixed"
);
}
#[test]
fn md_plane_appends_plain_markdown_pick_record() {
let record = picked_block(CHOICE, None);
let md = "answer line";
let rewrite = pick_rewrite(
Some(("<p>answer</p>", true, Some(md))),
"<p>escaped</p>",
&record,
0,
);
let PickRewrite::RichMarkdownHost(body) = rewrite else {
panic!("markdown host must ride the markdown plane: {rewrite:?}")
};
assert!(body.ends_with(&record), "plain md record last: {body}");
assert!(
!body.contains("<p>escaped</p>"),
"html record must not ride the md plane: {body}"
);
}
#[test]
fn go_tier_lines_markdown_renders_119_lines() {
let list = go_tier_lines(&["первый".to_string(), "второй|с таблицей".to_string()]);
assert_eq!(list, "1. первый\n2. второй|с таблицей");
let single = go_tier_lines(&["первый".to_string()]);
assert_eq!(single, "**Go: первый?**");
}