use std::collections::BTreeMap;
use topcoat::context::{Cx, CxTestBuilder};
use super::*;
use crate::{query_term::MAX_QUERY_TERM, topcoat_compat::href::encode_path_segment};
fn cx_with_query(query: &str) -> Cx {
let uri = if query.is_empty() {
"/admin".to_string()
} else {
format!("/admin?{query}")
};
let (parts, ()) = http::Request::builder()
.uri(uri)
.body(())
.unwrap()
.into_parts();
CxTestBuilder::new().request_context(parts).build()
}
#[test]
fn table_state_parses_query_params() {
let cx = cx_with_query("q=Ada+Lovelace&sort=name&dir=desc&after=abc123");
let state = TableState::from_cx(&cx);
assert_eq!(
state.search.as_deref(),
Some("Ada Lovelace"),
"plus must decode to space"
);
assert_eq!(
state.sort,
Some(Sort {
column: "name".to_string(),
descending: true,
})
);
assert_eq!(state.cursor, Some(Cursor::After("abc123".to_string())));
assert_eq!(TableState::from_query(""), TableState::default());
assert_eq!(
TableState::from_query("q=&sort=&dir=weird"),
TableState::default()
);
}
#[test]
fn from_cx_is_from_query_over_the_request() {
let query = "q=Ada&sort=name&dir=desc&f.status=published&group_by=status&before=tok";
assert_eq!(
TableState::from_cx(&cx_with_query(query)),
TableState::from_query(query)
);
}
#[test]
fn the_search_term_is_clamped() {
let long = "a".repeat(MAX_QUERY_TERM + 50);
let state = TableState::from_query(&format!("q={long}"));
assert_eq!(
state.search.as_deref().map(|s| s.chars().count()),
Some(MAX_QUERY_TERM)
);
}
#[test]
fn table_state_duplicate_params_keep_first_and_never_fail_open() {
let state = TableState::from_query("f.status=published&f.status=draft&q=Ada&q=Grace");
assert_eq!(
state.filters.get("status").map(String::as_str),
Some("published"),
"first occurrence must win, not vanish"
);
assert_eq!(state.search.as_deref(), Some("Ada"));
}
#[test]
fn filters_are_one_parameter_each() {
let state = TableState::from_query("f.status=published&f.featured=true&f.empty=&f.=x");
assert_eq!(
state.filters,
BTreeMap::from([
("featured".to_string(), "true".to_string()),
("status".to_string(), "published".to_string()),
]),
"a blank value or a blank name is no filter"
);
assert!(!state.filters_dropped);
}
#[test]
fn a_filter_value_with_separators_round_trips() {
let mut state = TableState::default();
state
.filters
.insert("author".to_string(), "Smith, John: 50% & co".to_string());
assert_eq!(TableState::from_query(&state.query()), state);
}
#[test]
fn filters_past_the_cap_are_dropped_and_flagged() {
let query = (0..MAX_FILTERS + 5)
.map(|i| format!("f.k{i}=v"))
.collect::<Vec<_>>()
.join("&");
let state = TableState::from_query(&query);
assert_eq!(state.filters.len(), MAX_FILTERS);
assert!(
state.filters_dropped,
"the dropped filters must be reported, so the export refuses"
);
}
#[test]
fn oversized_filters_are_dropped_and_flagged() {
let long = "a".repeat(MAX_FILTER_LEN + 1);
for query in [format!("f.status={long}"), format!("f.{long}=v")] {
let state = TableState::from_query(&format!("{query}&f.featured=true"));
assert_eq!(
state.filters,
BTreeMap::from([("featured".to_string(), "true".to_string())])
);
assert!(state.filters_dropped);
}
}
#[test]
fn the_retired_filters_parameter_is_flagged_not_ignored() {
assert!(TableState::from_query("filters=status:draft").filters_dropped);
assert!(!TableState::from_query("filters=").filters_dropped);
}
#[test]
fn unknown_keys_are_skipped_without_being_remembered() {
let query = (0..20_000)
.map(|i| format!("x{i}=v"))
.chain(["q=Ada".to_string(), "q=Grace".to_string()])
.collect::<Vec<_>>()
.join("&");
let state = TableState::from_query(&query);
assert_eq!(state.search.as_deref(), Some("Ada"));
}
#[test]
fn both_cursors_parse_as_the_first_page() {
assert_eq!(TableState::from_query("after=a&before=b").cursor, None);
assert_eq!(
TableState::from_query("before=b").cursor,
Some(Cursor::Before("b".to_string()))
);
}
#[test]
fn path_segment_encoding_keeps_uuids_and_escapes_reserved() {
let uuid = uuid::Uuid::nil().to_string();
assert_eq!(encode_path_segment(&uuid), uuid);
assert_eq!(encode_path_segment("a/b"), "a%2Fb");
assert_eq!(encode_path_segment("a+b@c.com"), "a%2Bb%40c.com");
assert_eq!(encode_path_segment("100%"), "100%25");
assert_eq!(encode_path_segment("a?b#c"), "a%3Fb%23c");
}
#[test]
fn row_dom_ids_are_stable_and_html_safe() {
assert_eq!(
row_dom_id("550e8400-e29b-41d4-a716-446655440000"),
row_dom_id("550e8400-e29b-41d4-a716-446655440000"),
"ids must be stable per key"
);
let uuid_id = row_dom_id("550e8400-e29b-41d4-a716-446655440000");
assert!(uuid_id.starts_with("row-550e8400-e29b-41d4-a716-446655440000-"));
assert!(
uuid_id.is_ascii(),
"id stays an ASCII token, got {uuid_id:?}"
);
assert_ne!(row_dom_id("Ada Lovelace"), row_dom_id("Ada-Lovelace"));
assert_ne!(row_dom_id("a/b?c"), row_dom_id("a-b-c"));
}
#[test]
fn group_header_dom_ids_are_stable_and_distinct_from_row_ids() {
assert_eq!(
group_header_dom_id("draft"),
group_header_dom_id("draft"),
"ids must be stable per label"
);
let draft = group_header_dom_id("draft");
assert!(
draft.starts_with("group-draft-"),
"the id names its group, got {draft:?}"
);
assert!(
group_header_dom_id("New York").starts_with("group-New-York-"),
"labels sanitize to HTML-safe tokens, got {:?}",
group_header_dom_id("New York")
);
assert_ne!(
group_header_dom_id("New York"),
group_header_dom_id("New-York")
);
assert_ne!(group_header_dom_id("draft"), row_dom_id("draft"));
}
fn populated_state() -> TableState {
TableState {
prefix: None,
search: Some("Ada".to_string()),
sort: Some(Sort {
column: "name".to_string(),
descending: true,
}),
cursor: Some(Cursor::After("after-cur".to_string())),
filters: BTreeMap::from([("status".to_string(), "published".to_string())]),
filters_dropped: false,
group_by: Some("status".to_string()),
delete: Some("row-1".to_string()),
open: Some(false),
}
}
fn reparse(url: &str) -> TableState {
TableState::from_query(query_of(url))
}
fn without_dialog(mut state: TableState) -> TableState {
state.delete = None;
state.open = None;
state
}
#[test]
fn projection_list_url_round_trips_full_state() {
let source = populated_state();
assert_eq!(
reparse(&source.list_url("/admin/users")),
without_dialog(source.clone())
);
assert_eq!(
TableState::from_query(&source.query()),
without_dialog(source)
);
}
#[test]
fn projection_without_search_drops_query() {
let source = populated_state();
let mut expected = without_dialog(source.clone());
expected.search = None;
expected.cursor = None;
assert_eq!(reparse(&source.without_search("/admin/users")), expected);
}
#[test]
fn projection_without_filters_drops_filters() {
let source = populated_state();
let mut expected = without_dialog(source.clone());
expected.filters = BTreeMap::new();
expected.cursor = None;
assert_eq!(reparse(&source.without_filters("/admin/users")), expected);
}
#[test]
fn projection_without_cursor_drops_pagination() {
let source = populated_state();
let mut expected = without_dialog(source.clone());
expected.cursor = None;
assert_eq!(reparse(&source.without_cursor("/admin/users")), expected);
}
#[test]
fn projection_with_cursor_replaces_the_cursor() {
let source = populated_state();
for cursor in [
Cursor::After("tok2".to_string()),
Cursor::Before("tok2".to_string()),
] {
let mut expected = without_dialog(source.clone());
expected.cursor = Some(cursor.clone());
assert_eq!(
reparse(&source.with_cursor("/admin/users", &cursor)),
expected
);
}
}
#[test]
fn projection_sorted_by_replaces_sort() {
let source = populated_state();
let mut expected = without_dialog(source.clone());
expected.sort = Some(Sort {
column: "title".to_string(),
descending: false,
});
expected.cursor = None;
assert_eq!(
reparse(&source.sorted_by("/admin/users", "title", false)),
expected
);
}
#[test]
fn projection_row_url_base_adds_the_delete_dialog_key() {
let source = populated_state();
let mut expected = source.clone();
expected.delete = Some("row-9".to_string());
expected.open = None;
assert_eq!(
reparse(&source.row_url_base("/admin/users").delete_dialog("row-9")),
expected
);
assert_eq!(
TableState::default()
.row_url_base("/admin/users")
.delete_dialog("row-9"),
"/admin/users?delete=row-9"
);
}
#[test]
fn bulk_wire_membership_is_exact() {
let wire = ",row-1,row-2,";
assert!(bulk_wire_contains(wire, "row-1"));
assert!(bulk_wire_contains(wire, "row-2"));
assert!(!bulk_wire_contains(wire, "row"));
assert!(!bulk_wire_contains(wire, "row-1a"));
assert!(!bulk_wire_contains(",row-12,", "row-1"));
assert!(!bulk_wire_contains("", "row-1"));
}
#[test]
fn prefixed_states_share_one_query_without_colliding() {
let query = "q=list&comments.q=ada&comments.sort=body&comments.dir=desc\
&comments.f.status=open&tags.q=rust&comments.after=tok";
let comments = TableState::from_query_prefixed(query, "comments");
assert_eq!(comments.prefix.as_deref(), Some("comments"));
assert_eq!(comments.search.as_deref(), Some("ada"));
assert_eq!(
comments.sort,
Some(Sort {
column: "body".to_string(),
descending: true
})
);
assert_eq!(
comments.filters.get("status").map(String::as_str),
Some("open")
);
assert_eq!(comments.cursor, Some(Cursor::After("tok".to_string())));
assert_eq!(
TableState::from_query_prefixed(query, "tags")
.search
.as_deref(),
Some("rust")
);
assert_eq!(
TableState::from_query(query).search.as_deref(),
Some("list")
);
let url = comments.list_url("/admin/posts/1");
assert_eq!(
url,
"/admin/posts/1?comments.q=ada&comments.sort=body&comments.dir=desc\
&comments.f.status=open&comments.after=tok"
);
assert_eq!(
TableState::from_query_prefixed(query_of(&url), "comments"),
comments,
"a prefixed link round-trips"
);
assert!(
comments
.row_url_base("/admin/posts/1")
.delete_dialog("c1")
.ends_with("&comments.delete=c1")
);
}