Lines
0 %
Functions
Branches
100 %
// -- -*- mode: rust -*-
//! Tests for fixed behaviors in the WASM frontend
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
/// Test that change events created with `EventInit` bubble up to parent elements.
/// This is critical for the currency mismatch detection which uses a document-level
/// event listener to catch change events from commodity input fields.
#[wasm_bindgen_test]
fn test_change_event_bubbles_to_document() {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
// Create a container and input
let container = document.create_element("div").unwrap();
let input = document
.create_element("input")
.unwrap()
.dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
container.append_child(&input).unwrap();
document.body().unwrap().append_child(&container).unwrap();
// Track if the event bubbled to the container
let bubbled = std::rc::Rc::new(std::cell::Cell::new(false));
let bubbled_clone = bubbled.clone();
let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
bubbled_clone.set(true);
}) as Box<dyn FnMut(_)>);
container
.add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
callback.forget();
// Create a bubbling change event (like our autocomplete does)
let init = web_sys::EventInit::new();
init.set_bubbles(true);
let event = web_sys::Event::new_with_event_init_dict("change", &init).unwrap();
input.dispatch_event(&event).unwrap();
assert!(
bubbled.get(),
"Change event should bubble to parent container"
);
// Cleanup
container.remove();
}
/// Test that non-bubbling events do NOT reach parent elements.
/// This demonstrates why we needed to add bubbles: true.
fn test_non_bubbling_event_stays_local() {
// Create a NON-bubbling event (default behavior without EventInit)
let event = web_sys::Event::new("change").unwrap();
!bubbled.get(),
"Non-bubbling event should NOT reach parent container"
/// Test that mousedown with preventDefault stops blur from happening.
/// This is critical for mobile Safari autocomplete selection.
fn test_mousedown_prevent_default_stops_blur() {
let dropdown = document.create_element("div").unwrap();
document.body().unwrap().append_child(&input).unwrap();
document.body().unwrap().append_child(&dropdown).unwrap();
// Add mousedown handler that prevents default (like our autocomplete)
let mousedown_callback =
wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
e.prevent_default();
dropdown
.add_event_listener_with_callback("mousedown", mousedown_callback.as_ref().unchecked_ref())
mousedown_callback.forget();
// Focus the input
input.focus().unwrap();
// The mousedown with preventDefault should not cause blur
// (In a real browser, clicking the dropdown would blur the input without this)
let init = web_sys::MouseEventInit::new();
init.set_cancelable(true);
let mousedown =
web_sys::MouseEvent::new_with_mouse_event_init_dict("mousedown", &init).unwrap();
let default_prevented = !dropdown.dispatch_event(&mousedown).unwrap();
default_prevented,
"mousedown preventDefault should be called"
input.remove();
dropdown.remove();
/// Test that detecting existing split entries works correctly.
/// The edit page should not fetch new splits if splits already exist.
fn test_split_entry_detection() {
// Create splits container WITHOUT any split entries (create page scenario)
container.set_id("splits-container");
// Should NOT find any split entries
let has_splits = container
.query_selector(".split-entry")
.ok()
.flatten()
.is_some();
assert!(!has_splits, "Empty container should have no split entries");
// Now add a split entry (edit page scenario)
let split = document.create_element("div").unwrap();
split.set_class_name("split-entry");
container.append_child(&split).unwrap();
// Should find the split entry
assert!(has_splits, "Container with split-entry should be detected");
/// Test the UTC to local conversion that happens on the edit page.
/// Server sends UTC, browser converts to local for display, then back to UTC on submit.
fn test_utc_to_local_conversion() {
input.set_type("datetime-local");
input.set_id("test-date");
// Simulate server sending UTC time (e.g., "2023-06-15T14:30")
let utc_value = "2023-06-15T14:30";
input.set_value(utc_value);
// Convert UTC to local (like our WASM code does)
let utc_string = format!("{utc_value}Z");
let utc_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(&utc_string));
let offset_ms = utc_date.get_timezone_offset() * 60.0 * 1000.0;
let local_time = utc_date.get_time() - offset_ms;
let local_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(local_time));
let iso_string = local_date.to_iso_string();
let local_value: String = iso_string
.as_string()
.map(|s| s.chars().take(16).collect())
.unwrap_or_default();
input.set_value(&local_value);
// Now simulate what json-enc.js does on submit: convert local back to UTC
let submit_value = input.value();
let submit_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(&submit_value));
let submit_utc = submit_date.to_iso_string().as_string().unwrap();
// The round-trip should preserve the original UTC time
submit_utc.starts_with("2023-06-15T14:30"),
"Round-trip should preserve UTC time. Got: {submit_utc}"
/// Test that input events also bubble (used for autocomplete filtering).
fn test_input_event_bubbles() {
.add_event_listener_with_callback("input", callback.as_ref().unchecked_ref())
let event = web_sys::Event::new_with_event_init_dict("input", &init).unwrap();
assert!(bubbled.get(), "Input event should bubble to parent");
/// Helper: build the entity-tag-template used by `entity_form_tag` functions.
fn create_entity_tag_template(document: &web_sys::Document) -> web_sys::HtmlTemplateElement {
let template = document
.create_element("template")
.dyn_into::<web_sys::HtmlTemplateElement>()
template.set_id("entity-tag-template");
let row = document.create_element("div").unwrap();
row.set_class_name("tag-input-row");
for (class, ac_type) in [
("tag-name-wrapper", Some("tag-name")),
("tag-value-wrapper", Some("tag-value")),
("", None),
] {
let wrapper = document.create_element("div").unwrap();
let mut wrapper_class = "autocomplete-wrapper".to_string();
if !class.is_empty() {
wrapper_class.push(' ');
wrapper_class.push_str(class);
wrapper.set_class_name(&wrapper_class);
if let Some(ac) = ac_type {
wrapper.set_attribute("data-autocomplete", ac).unwrap();
wrapper
.set_attribute("data-fetch-url", &format!("/api/tags/transaction/{ac}s"))
let results = document.create_element("div").unwrap();
results.set_class_name("autocomplete-results");
wrapper.append_child(&results).unwrap();
let input_class = match class {
"tag-name-wrapper" => "tag-name-input",
"tag-value-wrapper" => "tag-value-input",
_ => "tag-description-input",
};
let input = document.create_element("input").unwrap();
input.set_class_name(input_class);
input.set_attribute("type", "text").unwrap();
wrapper.append_child(&input).unwrap();
row.append_child(&wrapper).unwrap();
let btn = document.create_element("button").unwrap();
btn.set_class_name("button danger small remove-tag-btn");
row.append_child(&btn).unwrap();
template.content().append_child(&row).unwrap();
document.body().unwrap().append_child(&template).unwrap();
template
/// Helper: build the entity tags editor DOM (form-group > tag-editor > container + add-row).
fn create_entity_tag_editor(document: &web_sys::Document) -> (web_sys::Element, web_sys::Element) {
let form_group = document.create_element("div").unwrap();
form_group.set_class_name("form-group");
let editor = document.create_element("div").unwrap();
editor.set_class_name("tag-editor");
form_group.append_child(&editor).unwrap();
container.set_class_name("tags-container entity-tags-container");
editor.append_child(&container).unwrap();
let add_row = document.create_element("div").unwrap();
add_row.set_class_name("tag-add-row tag-input-row");
for class in ["tag-name-input", "tag-value-input", "tag-desc-input"] {
input.set_class_name(class);
add_row.append_child(&input).unwrap();
let btn = document
.create_element("button")
.dyn_into::<web_sys::HtmlElement>()
btn.set_class_name("button small add-tag-btn");
add_row.append_child(&btn).unwrap();
editor.append_child(&add_row).unwrap();
document.body().unwrap().append_child(&form_group).unwrap();
(form_group, container)
/// Test that `init_all()` marks a tag-name autocomplete wrapper as initialized
/// when `data-display-input` points to a valid input element.
fn test_autocomplete_init_marks_wrapper_initialized() {
let document = web_sys::window().unwrap().document().unwrap();
.set_attribute("data-autocomplete", "tag-name")
.set_attribute("data-fetch-url", "/api/tags/transaction/names")
.set_attribute("data-display-input", "test-ac-input")
input.set_id("test-ac-input");
document.body().unwrap().append_child(&wrapper).unwrap();
nomisync_frontend::autocomplete::init_all();
assert_eq!(
wrapper.get_attribute("data-initialized").as_deref(),
Some("true"),
"Wrapper with valid data-display-input should be marked initialized"
wrapper.remove();
/// Test that `init_all()` still marks a wrapper as initialized even when
/// data-display-input is missing (attach fails but `init_all` proceeds).
fn test_autocomplete_init_marks_wrapper_without_display_input() {
// deliberately no data-display-input
"Wrapper without data-display-input is still marked initialized (attach fails silently)"
/// Test that `add_entity_form_tag` sets data-display-input on cloned row wrappers,
/// enabling autocomplete initialization.
fn test_add_entity_form_tag_sets_display_input_attrs() {
let template = create_entity_tag_template(&document);
let (form_group, container) = create_entity_tag_editor(&document);
// Pre-fill add row
let add_name: web_sys::HtmlInputElement = form_group
.query_selector(".tag-add-row .tag-name-input")
.dyn_into()
add_name.set_value("category");
let add_value: web_sys::HtmlInputElement = form_group
.query_selector(".tag-add-row .tag-value-input")
add_value.set_value("food");
// Click the add button
let btn: web_sys::HtmlElement = form_group
.query_selector(".add-tag-btn")
nomisync_frontend::add_entity_form_tag(&btn);
// Verify a row was added
let rows = container.query_selector_all(".tag-input-row").unwrap();
assert_eq!(rows.length(), 1, "One tag row should be created");
let row: web_sys::Element = rows.get(0).unwrap().dyn_into().unwrap();
// Verify data-display-input is set on the name wrapper
let name_wrapper = row.query_selector(".tag-name-wrapper").unwrap().unwrap();
let display_input_attr = name_wrapper.get_attribute("data-display-input");
display_input_attr.is_some(),
"Name wrapper should have data-display-input"
display_input_attr.unwrap(),
"entity-tag-0-name",
"data-display-input should reference the name input ID"
// Verify data-display-input is set on the value wrapper
let value_wrapper = row.query_selector(".tag-value-wrapper").unwrap().unwrap();
let display_input_attr = value_wrapper.get_attribute("data-display-input");
"Value wrapper should have data-display-input"
"entity-tag-0-value",
"data-display-input should reference the value input ID"
// Verify data-depends-on is set on the value wrapper
let depends_on = value_wrapper.get_attribute("data-depends-on");
depends_on.as_deref(),
Some("entity-tag-0-name"),
"Value wrapper should depend on the name input"
// Verify the input IDs match
let name_input: web_sys::HtmlInputElement = row
.query_selector(".tag-name-input")
assert_eq!(name_input.id(), "entity-tag-0-name");
assert_eq!(name_input.value(), "category", "Name should be pre-filled");
let value_input: web_sys::HtmlInputElement = row
.query_selector(".tag-value-input")
assert_eq!(value_input.id(), "entity-tag-0-value");
assert_eq!(value_input.value(), "food", "Value should be pre-filled");
// Verify add row was cleared
assert_eq!(add_name.value(), "", "Add row name should be cleared");
assert_eq!(add_value.value(), "", "Add row value should be cleared");
form_group.remove();
template.remove();
/// Test that `load_existing_entity_tags` creates rows with proper autocomplete attributes.
fn test_load_existing_entity_tags_sets_display_input_attrs() {
// Hidden table with existing tags
let table_div = document.create_element("div").unwrap();
table_div.set_class_name("entity-tags-table");
table_div.set_attribute("style", "display: none;").unwrap();
let table = document.create_element("table").unwrap();
let tbody = document.create_element("tbody").unwrap();
for (name, value, desc) in [("region", "eu-west", ""), ("env", "prod", "production")] {
let tr = document.create_element("tr").unwrap();
for text in [name, value, desc] {
let td = document.create_element("td").unwrap();
td.set_text_content(Some(text));
tr.append_child(&td).unwrap();
tbody.append_child(&tr).unwrap();
table.append_child(&tbody).unwrap();
table_div.append_child(&table).unwrap();
form_group.append_child(&table_div).unwrap();
nomisync_frontend::load_existing_entity_tags();
assert_eq!(rows.length(), 2, "Two tag rows should be created");
// First row: region / eu-west
let row0: web_sys::Element = rows.get(0).unwrap().dyn_into().unwrap();
let name_wrapper = row0.query_selector(".tag-name-wrapper").unwrap().unwrap();
name_wrapper.get_attribute("data-display-input").as_deref(),
"First row name wrapper should have data-display-input"
let name_input: web_sys::HtmlInputElement = row0
assert_eq!(name_input.value(), "region");
// Second row: env / prod / production
let row1: web_sys::Element = rows.get(1).unwrap().dyn_into().unwrap();
let value_wrapper = row1.query_selector(".tag-value-wrapper").unwrap().unwrap();
value_wrapper.get_attribute("data-display-input").as_deref(),
Some("entity-tag-1-value"),
"Second row value wrapper should have data-display-input"
value_wrapper.get_attribute("data-depends-on").as_deref(),
Some("entity-tag-1-name"),
"Second row value wrapper should depend on name input"
let desc_input: web_sys::HtmlInputElement = row1
.query_selector(".tag-description-input")
assert_eq!(desc_input.value(), "production");
/// Test that `remove_entity_form_tag` removes the closest tag-input-row.
fn test_remove_entity_form_tag() {
// Add two rows
.query_selector_all(".tag-input-row")
.length(),
2
// Remove the first row via its button
let remove_btn: web_sys::HtmlElement = container
.query_selector(".remove-tag-btn")
nomisync_frontend::remove_entity_form_tag(&remove_btn);
1,
"One row should remain after removal"
/// Test that `load_existing_split_tags` reads hidden table rows and creates input rows.
fn test_load_existing_split_tags() {
// Create a form-group with split-tags-table and split-tags-container
container.set_class_name("tags-container split-tags-container");
container.set_attribute("data-split-index", "0").unwrap();
// Create hidden table with existing tags
table_div.set_class_name("split-tags-table");
let td_name = document.create_element("td").unwrap();
td_name.set_text_content(Some("category"));
tr.append_child(&td_name).unwrap();
let td_value = document.create_element("td").unwrap();
td_value.set_text_content(Some("food"));
tr.append_child(&td_value).unwrap();
let td_desc = document.create_element("td").unwrap();
td_desc.set_text_content(Some("groceries"));
tr.append_child(&td_desc).unwrap();
// Create the split-tag-template
template.set_id("split-tag-template");
let name_wrapper = document.create_element("div").unwrap();
name_wrapper.set_class_name("autocomplete-wrapper tag-name-wrapper");
let name_input = document.create_element("input").unwrap();
name_input.set_class_name("tag-name-input");
name_input.set_attribute("type", "text").unwrap();
name_wrapper.append_child(&name_input).unwrap();
let name_mirror = document.create_element("input").unwrap();
name_mirror.set_class_name("tag-name-mirror");
name_mirror.set_attribute("type", "hidden").unwrap();
name_wrapper.append_child(&name_mirror).unwrap();
row.append_child(&name_wrapper).unwrap();
let value_wrapper = document.create_element("div").unwrap();
value_wrapper.set_class_name("autocomplete-wrapper tag-value-wrapper");
let value_input = document.create_element("input").unwrap();
value_input.set_class_name("tag-value-input");
value_input.set_attribute("type", "text").unwrap();
value_wrapper.append_child(&value_input).unwrap();
let value_mirror = document.create_element("input").unwrap();
value_mirror.set_class_name("tag-value-mirror");
value_mirror.set_attribute("type", "hidden").unwrap();
value_wrapper.append_child(&value_mirror).unwrap();
row.append_child(&value_wrapper).unwrap();
let desc_wrapper = document.create_element("div").unwrap();
desc_wrapper.set_class_name("autocomplete-wrapper");
let desc_input = document.create_element("input").unwrap();
desc_input.set_class_name("tag-description-input");
desc_input.set_attribute("type", "text").unwrap();
desc_wrapper.append_child(&desc_input).unwrap();
let desc_mirror = document.create_element("input").unwrap();
desc_mirror.set_class_name("tag-description-mirror");
desc_mirror.set_attribute("type", "hidden").unwrap();
desc_wrapper.append_child(&desc_mirror).unwrap();
row.append_child(&desc_wrapper).unwrap();
let remove_btn = document.create_element("button").unwrap();
remove_btn.set_class_name("button danger small remove-tag-btn");
row.append_child(&remove_btn).unwrap();
// Call the function
nomisync_frontend::load_existing_split_tags();
// Verify a tag input row was created
let created_rows = container.query_selector_all(".tag-input-row").unwrap();
created_rows.length(),
"One tag input row should be created"
// Verify the values are set
let created_row = created_rows.get(0).unwrap();
let created_row: web_sys::Element = created_row.dyn_into().unwrap();
let name_el: web_sys::HtmlInputElement = created_row
assert_eq!(name_el.value(), "category", "Tag name should be 'category'");
let value_el: web_sys::HtmlInputElement = created_row
assert_eq!(value_el.value(), "food", "Tag value should be 'food'");
let desc_el: web_sys::HtmlInputElement = created_row
desc_el.value(),
"groceries",
"Tag description should be 'groceries'"
// Verify mirror fields are synced
let name_mirror_el: web_sys::HtmlInputElement = created_row
.query_selector(".tag-name-mirror")
name_mirror_el.value(),
"category",
"Name mirror should be synced"