1
// -- -*- mode: rust -*-
2
//! Tests for fixed behaviors in the WASM frontend
3

            
4
use wasm_bindgen::JsCast;
5
use wasm_bindgen_test::*;
6

            
7
wasm_bindgen_test_configure!(run_in_browser);
8

            
9
/// Test that change events created with `EventInit` bubble up to parent elements.
10
/// This is critical for the currency mismatch detection which uses a document-level
11
/// event listener to catch change events from commodity input fields.
12
#[wasm_bindgen_test]
13
fn test_change_event_bubbles_to_document() {
14
    let window = web_sys::window().unwrap();
15
    let document = window.document().unwrap();
16

            
17
    // Create a container and input
18
    let container = document.create_element("div").unwrap();
19
    let input = document
20
        .create_element("input")
21
        .unwrap()
22
        .dyn_into::<web_sys::HtmlInputElement>()
23
        .unwrap();
24
    container.append_child(&input).unwrap();
25
    document.body().unwrap().append_child(&container).unwrap();
26

            
27
    // Track if the event bubbled to the container
28
    let bubbled = std::rc::Rc::new(std::cell::Cell::new(false));
29
    let bubbled_clone = bubbled.clone();
30

            
31
    let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
32
        bubbled_clone.set(true);
33
    }) as Box<dyn FnMut(_)>);
34

            
35
    container
36
        .add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
37
        .unwrap();
38
    callback.forget();
39

            
40
    // Create a bubbling change event (like our autocomplete does)
41
    let init = web_sys::EventInit::new();
42
    init.set_bubbles(true);
43
    let event = web_sys::Event::new_with_event_init_dict("change", &init).unwrap();
44
    input.dispatch_event(&event).unwrap();
45

            
46
    assert!(
47
        bubbled.get(),
48
        "Change event should bubble to parent container"
49
    );
50

            
51
    // Cleanup
52
    container.remove();
53
}
54

            
55
/// Test that non-bubbling events do NOT reach parent elements.
56
/// This demonstrates why we needed to add bubbles: true.
57
#[wasm_bindgen_test]
58
fn test_non_bubbling_event_stays_local() {
59
    let window = web_sys::window().unwrap();
60
    let document = window.document().unwrap();
61

            
62
    let container = document.create_element("div").unwrap();
63
    let input = document
64
        .create_element("input")
65
        .unwrap()
66
        .dyn_into::<web_sys::HtmlInputElement>()
67
        .unwrap();
68
    container.append_child(&input).unwrap();
69
    document.body().unwrap().append_child(&container).unwrap();
70

            
71
    let bubbled = std::rc::Rc::new(std::cell::Cell::new(false));
72
    let bubbled_clone = bubbled.clone();
73

            
74
    let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
75
        bubbled_clone.set(true);
76
    }) as Box<dyn FnMut(_)>);
77

            
78
    container
79
        .add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
80
        .unwrap();
81
    callback.forget();
82

            
83
    // Create a NON-bubbling event (default behavior without EventInit)
84
    let event = web_sys::Event::new("change").unwrap();
85
    input.dispatch_event(&event).unwrap();
86

            
87
    assert!(
88
        !bubbled.get(),
89
        "Non-bubbling event should NOT reach parent container"
90
    );
91

            
92
    container.remove();
93
}
94

            
95
/// Test that mousedown with preventDefault stops blur from happening.
96
/// This is critical for mobile Safari autocomplete selection.
97
#[wasm_bindgen_test]
98
fn test_mousedown_prevent_default_stops_blur() {
99
    let window = web_sys::window().unwrap();
100
    let document = window.document().unwrap();
101

            
102
    let input = document
103
        .create_element("input")
104
        .unwrap()
105
        .dyn_into::<web_sys::HtmlInputElement>()
106
        .unwrap();
107
    let dropdown = document.create_element("div").unwrap();
108

            
109
    document.body().unwrap().append_child(&input).unwrap();
110
    document.body().unwrap().append_child(&dropdown).unwrap();
111

            
112
    // Add mousedown handler that prevents default (like our autocomplete)
113
    let mousedown_callback =
114
        wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
115
            e.prevent_default();
116
        }) as Box<dyn FnMut(_)>);
117

            
118
    dropdown
119
        .add_event_listener_with_callback("mousedown", mousedown_callback.as_ref().unchecked_ref())
120
        .unwrap();
121
    mousedown_callback.forget();
122

            
123
    // Focus the input
124
    input.focus().unwrap();
125

            
126
    // The mousedown with preventDefault should not cause blur
127
    // (In a real browser, clicking the dropdown would blur the input without this)
128
    let init = web_sys::MouseEventInit::new();
129
    init.set_bubbles(true);
130
    init.set_cancelable(true);
131
    let mousedown =
132
        web_sys::MouseEvent::new_with_mouse_event_init_dict("mousedown", &init).unwrap();
133
    let default_prevented = !dropdown.dispatch_event(&mousedown).unwrap();
134

            
135
    assert!(
136
        default_prevented,
137
        "mousedown preventDefault should be called"
138
    );
139

            
140
    input.remove();
141
    dropdown.remove();
142
}
143

            
144
/// Test that detecting existing split entries works correctly.
145
/// The edit page should not fetch new splits if splits already exist.
146
#[wasm_bindgen_test]
147
fn test_split_entry_detection() {
148
    let window = web_sys::window().unwrap();
149
    let document = window.document().unwrap();
150

            
151
    // Create splits container WITHOUT any split entries (create page scenario)
152
    let container = document.create_element("div").unwrap();
153
    container.set_id("splits-container");
154
    document.body().unwrap().append_child(&container).unwrap();
155

            
156
    // Should NOT find any split entries
157
    let has_splits = container
158
        .query_selector(".split-entry")
159
        .ok()
160
        .flatten()
161
        .is_some();
162
    assert!(!has_splits, "Empty container should have no split entries");
163

            
164
    // Now add a split entry (edit page scenario)
165
    let split = document.create_element("div").unwrap();
166
    split.set_class_name("split-entry");
167
    container.append_child(&split).unwrap();
168

            
169
    // Should find the split entry
170
    let has_splits = container
171
        .query_selector(".split-entry")
172
        .ok()
173
        .flatten()
174
        .is_some();
175
    assert!(has_splits, "Container with split-entry should be detected");
176

            
177
    container.remove();
178
}
179

            
180
/// Test the UTC to local conversion that happens on the edit page.
181
/// Server sends UTC, browser converts to local for display, then back to UTC on submit.
182
#[wasm_bindgen_test]
183
fn test_utc_to_local_conversion() {
184
    let window = web_sys::window().unwrap();
185
    let document = window.document().unwrap();
186

            
187
    let input = document
188
        .create_element("input")
189
        .unwrap()
190
        .dyn_into::<web_sys::HtmlInputElement>()
191
        .unwrap();
192
    input.set_type("datetime-local");
193
    input.set_id("test-date");
194
    document.body().unwrap().append_child(&input).unwrap();
195

            
196
    // Simulate server sending UTC time (e.g., "2023-06-15T14:30")
197
    let utc_value = "2023-06-15T14:30";
198
    input.set_value(utc_value);
199

            
200
    // Convert UTC to local (like our WASM code does)
201
    let utc_string = format!("{utc_value}Z");
202
    let utc_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(&utc_string));
203
    let offset_ms = utc_date.get_timezone_offset() * 60.0 * 1000.0;
204
    let local_time = utc_date.get_time() - offset_ms;
205
    let local_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(local_time));
206
    let iso_string = local_date.to_iso_string();
207
    let local_value: String = iso_string
208
        .as_string()
209
        .map(|s| s.chars().take(16).collect())
210
        .unwrap_or_default();
211

            
212
    input.set_value(&local_value);
213

            
214
    // Now simulate what json-enc.js does on submit: convert local back to UTC
215
    let submit_value = input.value();
216
    let submit_date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(&submit_value));
217
    let submit_utc = submit_date.to_iso_string().as_string().unwrap();
218

            
219
    // The round-trip should preserve the original UTC time
220
    assert!(
221
        submit_utc.starts_with("2023-06-15T14:30"),
222
        "Round-trip should preserve UTC time. Got: {submit_utc}"
223
    );
224

            
225
    input.remove();
226
}
227

            
228
/// Test that input events also bubble (used for autocomplete filtering).
229
#[wasm_bindgen_test]
230
fn test_input_event_bubbles() {
231
    let window = web_sys::window().unwrap();
232
    let document = window.document().unwrap();
233

            
234
    let container = document.create_element("div").unwrap();
235
    let input = document
236
        .create_element("input")
237
        .unwrap()
238
        .dyn_into::<web_sys::HtmlInputElement>()
239
        .unwrap();
240
    container.append_child(&input).unwrap();
241
    document.body().unwrap().append_child(&container).unwrap();
242

            
243
    let bubbled = std::rc::Rc::new(std::cell::Cell::new(false));
244
    let bubbled_clone = bubbled.clone();
245

            
246
    let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
247
        bubbled_clone.set(true);
248
    }) as Box<dyn FnMut(_)>);
249

            
250
    container
251
        .add_event_listener_with_callback("input", callback.as_ref().unchecked_ref())
252
        .unwrap();
253
    callback.forget();
254

            
255
    let init = web_sys::EventInit::new();
256
    init.set_bubbles(true);
257
    let event = web_sys::Event::new_with_event_init_dict("input", &init).unwrap();
258
    input.dispatch_event(&event).unwrap();
259

            
260
    assert!(bubbled.get(), "Input event should bubble to parent");
261

            
262
    container.remove();
263
}
264

            
265
/// Helper: build the entity-tag-template used by `entity_form_tag` functions.
266
fn create_entity_tag_template(document: &web_sys::Document) -> web_sys::HtmlTemplateElement {
267
    let template = document
268
        .create_element("template")
269
        .unwrap()
270
        .dyn_into::<web_sys::HtmlTemplateElement>()
271
        .unwrap();
272
    template.set_id("entity-tag-template");
273

            
274
    let row = document.create_element("div").unwrap();
275
    row.set_class_name("tag-input-row");
276

            
277
    for (class, ac_type) in [
278
        ("tag-name-wrapper", Some("tag-name")),
279
        ("tag-value-wrapper", Some("tag-value")),
280
        ("", None),
281
    ] {
282
        let wrapper = document.create_element("div").unwrap();
283
        let mut wrapper_class = "autocomplete-wrapper".to_string();
284
        if !class.is_empty() {
285
            wrapper_class.push(' ');
286
            wrapper_class.push_str(class);
287
        }
288
        wrapper.set_class_name(&wrapper_class);
289
        if let Some(ac) = ac_type {
290
            wrapper.set_attribute("data-autocomplete", ac).unwrap();
291
            wrapper
292
                .set_attribute("data-fetch-url", &format!("/api/tags/transaction/{ac}s"))
293
                .unwrap();
294
            let results = document.create_element("div").unwrap();
295
            results.set_class_name("autocomplete-results");
296
            wrapper.append_child(&results).unwrap();
297
        }
298
        let input_class = match class {
299
            "tag-name-wrapper" => "tag-name-input",
300
            "tag-value-wrapper" => "tag-value-input",
301
            _ => "tag-description-input",
302
        };
303
        let input = document.create_element("input").unwrap();
304
        input.set_class_name(input_class);
305
        input.set_attribute("type", "text").unwrap();
306
        wrapper.append_child(&input).unwrap();
307
        row.append_child(&wrapper).unwrap();
308
    }
309

            
310
    let btn = document.create_element("button").unwrap();
311
    btn.set_class_name("button danger small remove-tag-btn");
312
    row.append_child(&btn).unwrap();
313

            
314
    template.content().append_child(&row).unwrap();
315
    document.body().unwrap().append_child(&template).unwrap();
316
    template
317
}
318

            
319
/// Helper: build the entity tags editor DOM (form-group > tag-editor > container + add-row).
320
fn create_entity_tag_editor(document: &web_sys::Document) -> (web_sys::Element, web_sys::Element) {
321
    let form_group = document.create_element("div").unwrap();
322
    form_group.set_class_name("form-group");
323

            
324
    let editor = document.create_element("div").unwrap();
325
    editor.set_class_name("tag-editor");
326
    form_group.append_child(&editor).unwrap();
327

            
328
    let container = document.create_element("div").unwrap();
329
    container.set_class_name("tags-container entity-tags-container");
330
    editor.append_child(&container).unwrap();
331

            
332
    let add_row = document.create_element("div").unwrap();
333
    add_row.set_class_name("tag-add-row tag-input-row");
334

            
335
    for class in ["tag-name-input", "tag-value-input", "tag-desc-input"] {
336
        let input = document
337
            .create_element("input")
338
            .unwrap()
339
            .dyn_into::<web_sys::HtmlInputElement>()
340
            .unwrap();
341
        input.set_class_name(class);
342
        add_row.append_child(&input).unwrap();
343
    }
344

            
345
    let btn = document
346
        .create_element("button")
347
        .unwrap()
348
        .dyn_into::<web_sys::HtmlElement>()
349
        .unwrap();
350
    btn.set_class_name("button small add-tag-btn");
351
    add_row.append_child(&btn).unwrap();
352

            
353
    editor.append_child(&add_row).unwrap();
354
    document.body().unwrap().append_child(&form_group).unwrap();
355

            
356
    (form_group, container)
357
}
358

            
359
/// Test that `init_all()` marks a tag-name autocomplete wrapper as initialized
360
/// when `data-display-input` points to a valid input element.
361
#[wasm_bindgen_test]
362
fn test_autocomplete_init_marks_wrapper_initialized() {
363
    let document = web_sys::window().unwrap().document().unwrap();
364

            
365
    let wrapper = document.create_element("div").unwrap();
366
    wrapper
367
        .set_attribute("data-autocomplete", "tag-name")
368
        .unwrap();
369
    wrapper
370
        .set_attribute("data-fetch-url", "/api/tags/transaction/names")
371
        .unwrap();
372
    wrapper
373
        .set_attribute("data-display-input", "test-ac-input")
374
        .unwrap();
375

            
376
    let input = document.create_element("input").unwrap();
377
    input.set_id("test-ac-input");
378
    input.set_attribute("type", "text").unwrap();
379
    wrapper.append_child(&input).unwrap();
380

            
381
    let results = document.create_element("div").unwrap();
382
    results.set_class_name("autocomplete-results");
383
    wrapper.append_child(&results).unwrap();
384

            
385
    document.body().unwrap().append_child(&wrapper).unwrap();
386

            
387
    nomisync_frontend::autocomplete::init_all();
388

            
389
    assert_eq!(
390
        wrapper.get_attribute("data-initialized").as_deref(),
391
        Some("true"),
392
        "Wrapper with valid data-display-input should be marked initialized"
393
    );
394

            
395
    wrapper.remove();
396
}
397

            
398
/// Test that `init_all()` still marks a wrapper as initialized even when
399
/// data-display-input is missing (attach fails but `init_all` proceeds).
400
#[wasm_bindgen_test]
401
fn test_autocomplete_init_marks_wrapper_without_display_input() {
402
    let document = web_sys::window().unwrap().document().unwrap();
403

            
404
    let wrapper = document.create_element("div").unwrap();
405
    wrapper
406
        .set_attribute("data-autocomplete", "tag-name")
407
        .unwrap();
408
    wrapper
409
        .set_attribute("data-fetch-url", "/api/tags/transaction/names")
410
        .unwrap();
411
    // deliberately no data-display-input
412

            
413
    let input = document.create_element("input").unwrap();
414
    input.set_attribute("type", "text").unwrap();
415
    wrapper.append_child(&input).unwrap();
416

            
417
    let results = document.create_element("div").unwrap();
418
    results.set_class_name("autocomplete-results");
419
    wrapper.append_child(&results).unwrap();
420

            
421
    document.body().unwrap().append_child(&wrapper).unwrap();
422

            
423
    nomisync_frontend::autocomplete::init_all();
424

            
425
    assert_eq!(
426
        wrapper.get_attribute("data-initialized").as_deref(),
427
        Some("true"),
428
        "Wrapper without data-display-input is still marked initialized (attach fails silently)"
429
    );
430

            
431
    wrapper.remove();
432
}
433

            
434
/// Test that `add_entity_form_tag` sets data-display-input on cloned row wrappers,
435
/// enabling autocomplete initialization.
436
#[wasm_bindgen_test]
437
fn test_add_entity_form_tag_sets_display_input_attrs() {
438
    let document = web_sys::window().unwrap().document().unwrap();
439
    let template = create_entity_tag_template(&document);
440
    let (form_group, container) = create_entity_tag_editor(&document);
441

            
442
    // Pre-fill add row
443
    let add_name: web_sys::HtmlInputElement = form_group
444
        .query_selector(".tag-add-row .tag-name-input")
445
        .unwrap()
446
        .unwrap()
447
        .dyn_into()
448
        .unwrap();
449
    add_name.set_value("category");
450

            
451
    let add_value: web_sys::HtmlInputElement = form_group
452
        .query_selector(".tag-add-row .tag-value-input")
453
        .unwrap()
454
        .unwrap()
455
        .dyn_into()
456
        .unwrap();
457
    add_value.set_value("food");
458

            
459
    // Click the add button
460
    let btn: web_sys::HtmlElement = form_group
461
        .query_selector(".add-tag-btn")
462
        .unwrap()
463
        .unwrap()
464
        .dyn_into()
465
        .unwrap();
466
    nomisync_frontend::add_entity_form_tag(&btn);
467

            
468
    // Verify a row was added
469
    let rows = container.query_selector_all(".tag-input-row").unwrap();
470
    assert_eq!(rows.length(), 1, "One tag row should be created");
471

            
472
    let row: web_sys::Element = rows.get(0).unwrap().dyn_into().unwrap();
473

            
474
    // Verify data-display-input is set on the name wrapper
475
    let name_wrapper = row.query_selector(".tag-name-wrapper").unwrap().unwrap();
476
    let display_input_attr = name_wrapper.get_attribute("data-display-input");
477
    assert!(
478
        display_input_attr.is_some(),
479
        "Name wrapper should have data-display-input"
480
    );
481
    assert_eq!(
482
        display_input_attr.unwrap(),
483
        "entity-tag-0-name",
484
        "data-display-input should reference the name input ID"
485
    );
486

            
487
    // Verify data-display-input is set on the value wrapper
488
    let value_wrapper = row.query_selector(".tag-value-wrapper").unwrap().unwrap();
489
    let display_input_attr = value_wrapper.get_attribute("data-display-input");
490
    assert!(
491
        display_input_attr.is_some(),
492
        "Value wrapper should have data-display-input"
493
    );
494
    assert_eq!(
495
        display_input_attr.unwrap(),
496
        "entity-tag-0-value",
497
        "data-display-input should reference the value input ID"
498
    );
499

            
500
    // Verify data-depends-on is set on the value wrapper
501
    let depends_on = value_wrapper.get_attribute("data-depends-on");
502
    assert_eq!(
503
        depends_on.as_deref(),
504
        Some("entity-tag-0-name"),
505
        "Value wrapper should depend on the name input"
506
    );
507

            
508
    // Verify the input IDs match
509
    let name_input: web_sys::HtmlInputElement = row
510
        .query_selector(".tag-name-input")
511
        .unwrap()
512
        .unwrap()
513
        .dyn_into()
514
        .unwrap();
515
    assert_eq!(name_input.id(), "entity-tag-0-name");
516
    assert_eq!(name_input.value(), "category", "Name should be pre-filled");
517

            
518
    let value_input: web_sys::HtmlInputElement = row
519
        .query_selector(".tag-value-input")
520
        .unwrap()
521
        .unwrap()
522
        .dyn_into()
523
        .unwrap();
524
    assert_eq!(value_input.id(), "entity-tag-0-value");
525
    assert_eq!(value_input.value(), "food", "Value should be pre-filled");
526

            
527
    // Verify add row was cleared
528
    assert_eq!(add_name.value(), "", "Add row name should be cleared");
529
    assert_eq!(add_value.value(), "", "Add row value should be cleared");
530

            
531
    form_group.remove();
532
    template.remove();
533
}
534

            
535
/// Test that `load_existing_entity_tags` creates rows with proper autocomplete attributes.
536
#[wasm_bindgen_test]
537
fn test_load_existing_entity_tags_sets_display_input_attrs() {
538
    let document = web_sys::window().unwrap().document().unwrap();
539
    let template = create_entity_tag_template(&document);
540

            
541
    let form_group = document.create_element("div").unwrap();
542
    form_group.set_class_name("form-group");
543

            
544
    let editor = document.create_element("div").unwrap();
545
    editor.set_class_name("tag-editor");
546
    form_group.append_child(&editor).unwrap();
547

            
548
    let container = document.create_element("div").unwrap();
549
    container.set_class_name("tags-container entity-tags-container");
550
    editor.append_child(&container).unwrap();
551

            
552
    // Hidden table with existing tags
553
    let table_div = document.create_element("div").unwrap();
554
    table_div.set_class_name("entity-tags-table");
555
    table_div.set_attribute("style", "display: none;").unwrap();
556

            
557
    let table = document.create_element("table").unwrap();
558
    let tbody = document.create_element("tbody").unwrap();
559

            
560
    for (name, value, desc) in [("region", "eu-west", ""), ("env", "prod", "production")] {
561
        let tr = document.create_element("tr").unwrap();
562
        for text in [name, value, desc] {
563
            let td = document.create_element("td").unwrap();
564
            td.set_text_content(Some(text));
565
            tr.append_child(&td).unwrap();
566
        }
567
        tbody.append_child(&tr).unwrap();
568
    }
569

            
570
    table.append_child(&tbody).unwrap();
571
    table_div.append_child(&table).unwrap();
572
    form_group.append_child(&table_div).unwrap();
573

            
574
    document.body().unwrap().append_child(&form_group).unwrap();
575

            
576
    nomisync_frontend::load_existing_entity_tags();
577

            
578
    let rows = container.query_selector_all(".tag-input-row").unwrap();
579
    assert_eq!(rows.length(), 2, "Two tag rows should be created");
580

            
581
    // First row: region / eu-west
582
    let row0: web_sys::Element = rows.get(0).unwrap().dyn_into().unwrap();
583
    let name_wrapper = row0.query_selector(".tag-name-wrapper").unwrap().unwrap();
584
    assert_eq!(
585
        name_wrapper.get_attribute("data-display-input").as_deref(),
586
        Some("entity-tag-0-name"),
587
        "First row name wrapper should have data-display-input"
588
    );
589

            
590
    let name_input: web_sys::HtmlInputElement = row0
591
        .query_selector(".tag-name-input")
592
        .unwrap()
593
        .unwrap()
594
        .dyn_into()
595
        .unwrap();
596
    assert_eq!(name_input.id(), "entity-tag-0-name");
597
    assert_eq!(name_input.value(), "region");
598

            
599
    // Second row: env / prod / production
600
    let row1: web_sys::Element = rows.get(1).unwrap().dyn_into().unwrap();
601
    let value_wrapper = row1.query_selector(".tag-value-wrapper").unwrap().unwrap();
602
    assert_eq!(
603
        value_wrapper.get_attribute("data-display-input").as_deref(),
604
        Some("entity-tag-1-value"),
605
        "Second row value wrapper should have data-display-input"
606
    );
607
    assert_eq!(
608
        value_wrapper.get_attribute("data-depends-on").as_deref(),
609
        Some("entity-tag-1-name"),
610
        "Second row value wrapper should depend on name input"
611
    );
612

            
613
    let desc_input: web_sys::HtmlInputElement = row1
614
        .query_selector(".tag-description-input")
615
        .unwrap()
616
        .unwrap()
617
        .dyn_into()
618
        .unwrap();
619
    assert_eq!(desc_input.value(), "production");
620

            
621
    form_group.remove();
622
    template.remove();
623
}
624

            
625
/// Test that `remove_entity_form_tag` removes the closest tag-input-row.
626
#[wasm_bindgen_test]
627
fn test_remove_entity_form_tag() {
628
    let document = web_sys::window().unwrap().document().unwrap();
629
    let template = create_entity_tag_template(&document);
630
    let (form_group, container) = create_entity_tag_editor(&document);
631

            
632
    // Add two rows
633
    let btn: web_sys::HtmlElement = form_group
634
        .query_selector(".add-tag-btn")
635
        .unwrap()
636
        .unwrap()
637
        .dyn_into()
638
        .unwrap();
639
    nomisync_frontend::add_entity_form_tag(&btn);
640
    nomisync_frontend::add_entity_form_tag(&btn);
641

            
642
    assert_eq!(
643
        container
644
            .query_selector_all(".tag-input-row")
645
            .unwrap()
646
            .length(),
647
        2
648
    );
649

            
650
    // Remove the first row via its button
651
    let remove_btn: web_sys::HtmlElement = container
652
        .query_selector(".remove-tag-btn")
653
        .unwrap()
654
        .unwrap()
655
        .dyn_into()
656
        .unwrap();
657
    nomisync_frontend::remove_entity_form_tag(&remove_btn);
658

            
659
    assert_eq!(
660
        container
661
            .query_selector_all(".tag-input-row")
662
            .unwrap()
663
            .length(),
664
        1,
665
        "One row should remain after removal"
666
    );
667

            
668
    form_group.remove();
669
    template.remove();
670
}
671

            
672
/// Test that `load_existing_split_tags` reads hidden table rows and creates input rows.
673
#[wasm_bindgen_test]
674
fn test_load_existing_split_tags() {
675
    let window = web_sys::window().unwrap();
676
    let document = window.document().unwrap();
677

            
678
    // Create a form-group with split-tags-table and split-tags-container
679
    let form_group = document.create_element("div").unwrap();
680
    form_group.set_class_name("form-group");
681

            
682
    let editor = document.create_element("div").unwrap();
683
    editor.set_class_name("tag-editor");
684
    form_group.append_child(&editor).unwrap();
685

            
686
    let container = document.create_element("div").unwrap();
687
    container.set_class_name("tags-container split-tags-container");
688
    container.set_attribute("data-split-index", "0").unwrap();
689
    editor.append_child(&container).unwrap();
690

            
691
    // Create hidden table with existing tags
692
    let table_div = document.create_element("div").unwrap();
693
    table_div.set_class_name("split-tags-table");
694
    table_div.set_attribute("style", "display: none;").unwrap();
695

            
696
    let table = document.create_element("table").unwrap();
697
    let tbody = document.create_element("tbody").unwrap();
698
    let tr = document.create_element("tr").unwrap();
699

            
700
    let td_name = document.create_element("td").unwrap();
701
    td_name.set_text_content(Some("category"));
702
    tr.append_child(&td_name).unwrap();
703

            
704
    let td_value = document.create_element("td").unwrap();
705
    td_value.set_text_content(Some("food"));
706
    tr.append_child(&td_value).unwrap();
707

            
708
    let td_desc = document.create_element("td").unwrap();
709
    td_desc.set_text_content(Some("groceries"));
710
    tr.append_child(&td_desc).unwrap();
711

            
712
    tbody.append_child(&tr).unwrap();
713
    table.append_child(&tbody).unwrap();
714
    table_div.append_child(&table).unwrap();
715
    form_group.append_child(&table_div).unwrap();
716

            
717
    document.body().unwrap().append_child(&form_group).unwrap();
718

            
719
    // Create the split-tag-template
720
    let template = document
721
        .create_element("template")
722
        .unwrap()
723
        .dyn_into::<web_sys::HtmlTemplateElement>()
724
        .unwrap();
725
    template.set_id("split-tag-template");
726

            
727
    let row = document.create_element("div").unwrap();
728
    row.set_class_name("tag-input-row");
729

            
730
    let name_wrapper = document.create_element("div").unwrap();
731
    name_wrapper.set_class_name("autocomplete-wrapper tag-name-wrapper");
732
    let name_input = document.create_element("input").unwrap();
733
    name_input.set_class_name("tag-name-input");
734
    name_input.set_attribute("type", "text").unwrap();
735
    name_wrapper.append_child(&name_input).unwrap();
736
    let name_mirror = document.create_element("input").unwrap();
737
    name_mirror.set_class_name("tag-name-mirror");
738
    name_mirror.set_attribute("type", "hidden").unwrap();
739
    name_wrapper.append_child(&name_mirror).unwrap();
740
    row.append_child(&name_wrapper).unwrap();
741

            
742
    let value_wrapper = document.create_element("div").unwrap();
743
    value_wrapper.set_class_name("autocomplete-wrapper tag-value-wrapper");
744
    let value_input = document.create_element("input").unwrap();
745
    value_input.set_class_name("tag-value-input");
746
    value_input.set_attribute("type", "text").unwrap();
747
    value_wrapper.append_child(&value_input).unwrap();
748
    let value_mirror = document.create_element("input").unwrap();
749
    value_mirror.set_class_name("tag-value-mirror");
750
    value_mirror.set_attribute("type", "hidden").unwrap();
751
    value_wrapper.append_child(&value_mirror).unwrap();
752
    row.append_child(&value_wrapper).unwrap();
753

            
754
    let desc_wrapper = document.create_element("div").unwrap();
755
    desc_wrapper.set_class_name("autocomplete-wrapper");
756
    let desc_input = document.create_element("input").unwrap();
757
    desc_input.set_class_name("tag-description-input");
758
    desc_input.set_attribute("type", "text").unwrap();
759
    desc_wrapper.append_child(&desc_input).unwrap();
760
    let desc_mirror = document.create_element("input").unwrap();
761
    desc_mirror.set_class_name("tag-description-mirror");
762
    desc_mirror.set_attribute("type", "hidden").unwrap();
763
    desc_wrapper.append_child(&desc_mirror).unwrap();
764
    row.append_child(&desc_wrapper).unwrap();
765

            
766
    let remove_btn = document.create_element("button").unwrap();
767
    remove_btn.set_class_name("button danger small remove-tag-btn");
768
    row.append_child(&remove_btn).unwrap();
769

            
770
    template.content().append_child(&row).unwrap();
771
    document.body().unwrap().append_child(&template).unwrap();
772

            
773
    // Call the function
774
    nomisync_frontend::load_existing_split_tags();
775

            
776
    // Verify a tag input row was created
777
    let created_rows = container.query_selector_all(".tag-input-row").unwrap();
778
    assert_eq!(
779
        created_rows.length(),
780
        1,
781
        "One tag input row should be created"
782
    );
783

            
784
    // Verify the values are set
785
    let created_row = created_rows.get(0).unwrap();
786
    let created_row: web_sys::Element = created_row.dyn_into().unwrap();
787

            
788
    let name_el: web_sys::HtmlInputElement = created_row
789
        .query_selector(".tag-name-input")
790
        .unwrap()
791
        .unwrap()
792
        .dyn_into()
793
        .unwrap();
794
    assert_eq!(name_el.value(), "category", "Tag name should be 'category'");
795

            
796
    let value_el: web_sys::HtmlInputElement = created_row
797
        .query_selector(".tag-value-input")
798
        .unwrap()
799
        .unwrap()
800
        .dyn_into()
801
        .unwrap();
802
    assert_eq!(value_el.value(), "food", "Tag value should be 'food'");
803

            
804
    let desc_el: web_sys::HtmlInputElement = created_row
805
        .query_selector(".tag-description-input")
806
        .unwrap()
807
        .unwrap()
808
        .dyn_into()
809
        .unwrap();
810
    assert_eq!(
811
        desc_el.value(),
812
        "groceries",
813
        "Tag description should be 'groceries'"
814
    );
815

            
816
    // Verify mirror fields are synced
817
    let name_mirror_el: web_sys::HtmlInputElement = created_row
818
        .query_selector(".tag-name-mirror")
819
        .unwrap()
820
        .unwrap()
821
        .dyn_into()
822
        .unwrap();
823
    assert_eq!(
824
        name_mirror_el.value(),
825
        "category",
826
        "Name mirror should be synced"
827
    );
828

            
829
    // Cleanup
830
    form_group.remove();
831
    template.remove();
832
}