comic/odin/tests/gui_integration_phase39.odin
2026-05-22 03:51:50 +02:00

464 lines
18 KiB
Odin

package tests
import "core:fmt"
import "core:os"
import "core:strings"
import "core:testing"
import "../src/adapters"
import "../src/core"
import "../src/gui"
import "../src/shared"
import "../src/ui"
// ─────────────────────────────────────────────────────────────
// Milestone 34E: Layout constant validation at multiple resolutions
// ─────────────────────────────────────────────────────────────
@test
layout_constants_match_hardcoded_values :: proc(t: ^testing.T) {
testing.expect(t, shared.LAYOUT.sidebar_width == 282, "sidebar_width should match historical 282")
testing.expect(t, shared.LAYOUT.right_margin == 20, "right_margin should match historical 20")
testing.expect(t, shared.LAYOUT.min_main_width == 960, "min_main_width should match historical 960")
testing.expect(t, shared.LAYOUT.top_reserved_height == 252, "top_reserved_height should match historical 252")
testing.expect(t, shared.LAYOUT.min_lower_y == 450, "min_lower_y should match historical 450")
testing.expect(t, shared.LAYOUT.compact_height == 860, "compact_height should match historical 860")
}
@test
compute_main_width_formula :: proc(t: ^testing.T) {
// 1920x1080 standard
w := shared.compute_main_width(1920)
testing.expect(t, w == 1618, fmt.tprintf("1920px screen: expected 1618, got %d", w))
// 1366x768 compact
w2 := shared.compute_main_width(1366)
testing.expect(t, w2 == 1064, fmt.tprintf("1366px screen: expected 1064, got %d", w2))
// Ultrawide 2560x1440
w3 := shared.compute_main_width(2560)
testing.expect(t, w3 == 2258, fmt.tprintf("2560px screen: expected 2258, got %d", w3))
// Minimum floor
w4 := shared.compute_main_width(1200)
testing.expect(t, w4 == 960, fmt.tprintf("narrow screen: expected 960 floor, got %d", w4))
}
@test
compute_lower_y_formula :: proc(t: ^testing.T) {
// 1920x1080 standard
y := shared.compute_lower_y(1080)
testing.expect(t, y == 828, fmt.tprintf("1080px height: expected 828, got %d", y))
// 1366x768 compact
y2 := shared.compute_lower_y(768)
testing.expect(t, y2 == 516, fmt.tprintf("768px height: expected 516, got %d", y2))
// Minimum floor
y3 := shared.compute_lower_y(600)
testing.expect(t, y3 == 450, fmt.tprintf("short screen: expected 450 floor, got %d", y3))
}
@test
screen_profile_classification :: proc(t: ^testing.T) {
// Compact: height < 860
p1 := shared.screen_profile(1366, 768)
testing.expect(t, p1 == .Compact, "1366x768 should be Compact")
// Standard: height >= 860, width < 1920
p2 := shared.screen_profile(1440, 900)
testing.expect(t, p2 == .Standard, "1440x900 should be Standard")
// Wide: width >= 1920
p3 := shared.screen_profile(1920, 1080)
testing.expect(t, p3 == .Wide, "1920x1080 should be Wide")
// Ultrawide
p4 := shared.screen_profile(2560, 1440)
testing.expect(t, p4 == .Wide, "2560x1440 should be Wide")
}
@test
is_compact_helper :: proc(t: ^testing.T) {
testing.expect(t, shared.is_compact(768), "768px should be compact")
testing.expect(t, shared.is_compact(859), "859px should be compact")
testing.expect(t, !shared.is_compact(860), "860px should not be compact")
testing.expect(t, !shared.is_compact(1080), "1080px should not be compact")
}
// ─────────────────────────────────────────────────────────────
// Milestone 39A: GUI integration smoke tests for full local flow
// ─────────────────────────────────────────────────────────────
@test
gui_bubble_type_name_roundtrip :: proc(t: ^testing.T) {
types := []core.Bubble_Type{.Normal, .Thought, .Shout, .Whisper, .Narration, .Sound_Effect}
names := []string{"Normal", "Thought", "Shout", "Whisper", "Narration", "SFX"}
for name, i in names {
nt := gui.bubble_type_from_name(name)
testing.expect(t, nt == types[i], fmt.tprintf("bubble_type_from_name(%q) should map to correct type", name))
testing.expect(t, gui.bubble_type_name(types[i]) == name, fmt.tprintf("bubble_type_name should return %q", name))
}
}
@test
gui_clamp_bubble_cursor :: proc(t: ^testing.T) {
testing.expect(t, gui.clamp_bubble_cursor(0, 5) == 0, "zero count should return 0")
testing.expect(t, gui.clamp_bubble_cursor(3, -1) == 0, "negative cursor should clamp to 0")
testing.expect(t, gui.clamp_bubble_cursor(3, 3) == 2, "cursor at count should clamp to last index")
testing.expect(t, gui.clamp_bubble_cursor(3, 10) == 2, "cursor beyond count should clamp to last index")
testing.expect(t, gui.clamp_bubble_cursor(5, 2) == 2, "valid cursor should pass through")
}
@test
gui_layout_validation_coverage :: proc(t: ^testing.T) {
panels: [dynamic]core.Page_Layout_Panel
defer delete(panels)
// 4 panels filling the page exactly
for i in 0..<4 {
panel := core.Page_Layout_Panel{
panel_id = fmt.tprintf("panel_%d", i),
panel_number = i + 1,
layout_cell = core.Layout_Cell{
x = f32(i%2) * 0.5,
y = f32(i/2) * 0.5,
w = 0.5,
h = 0.5,
},
}
append(&panels, panel)
}
layout := core.Page_Layout{
page_number = 1,
pattern_id = "grid-2x2",
width = 800,
height = 1200,
panels = panels[:],
}
val := gui.validate_layout_page(layout, nil)
testing.expect(t, val.total_panels == 4, "should count 4 panels")
testing.expect(t, val.coverage_pct == 100.0, fmt.tprintf("coverage should be 100%%, got %.1f%%", val.coverage_pct))
testing.expect(t, val.missing_bindings == 4, "all 4 panels should be missing images")
testing.expect(t, val.bounds_violations == 0, "no bounds violations for valid cells")
}
@test
gui_layout_validation_bounds_violations :: proc(t: ^testing.T) {
panels: [dynamic]core.Page_Layout_Panel
defer delete(panels)
// Panel extending beyond bounds
panel := core.Page_Layout_Panel{
panel_id = "panel_1",
panel_number = 1,
layout_cell = core.Layout_Cell{
x = -0.1,
y = 0.0,
w = 1.2,
h = 1.0,
},
}
append(&panels, panel)
layout := core.Page_Layout{
page_number = 1,
pattern_id = "single-splash",
width = 800,
height = 1200,
panels = panels[:],
}
val := gui.validate_layout_page(layout, nil)
testing.expect(t, val.bounds_violations == 1, "should detect bounds violation for negative x and width > 1")
}
@test
gui_layout_validation_missing_bindings :: proc(t: ^testing.T) {
panels: [dynamic]core.Page_Layout_Panel
defer delete(panels)
panel := core.Page_Layout_Panel{
panel_id = "panel_1",
panel_number = 1,
layout_cell = core.Layout_Cell{x = 0, y = 0, w = 1, h = 1},
}
append(&panels, panel)
layout := core.Page_Layout{
page_number = 1,
pattern_id = "single-splash",
width = 800,
height = 1200,
panels = panels[:],
}
// No panel images at all
val := gui.validate_layout_page(layout, nil)
testing.expect(t, val.missing_bindings == 1, "should detect missing binding for panel without image")
// Panel image present
images := make(map[string]core.Panel_Image)
images["panel_1"] = core.Panel_Image{url = "test.png", width = 512, height = 512, seed = 42, prompt = "test"}
val2 := gui.validate_layout_page(layout, images)
testing.expect(t, val2.missing_bindings == 0, "should have no missing bindings when image exists")
delete(images)
}
// ─────────────────────────────────────────────────────────────
// Milestone 39B: Error-path tests
// ─────────────────────────────────────────────────────────────
@test
gui_action_regenerate_page_layout_invalid_page :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
msg := gui.action_regenerate_page_layout(&controller, -1)
testing.expect(t, msg == "Invalid layout page", "negative page index should return error")
msg2 := gui.action_regenerate_page_layout(&controller, 0)
testing.expect(t, msg2 == "Invalid layout page", "page index beyond layouts should return error")
}
@test
gui_action_regenerate_page_layout_no_panels :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
// Add empty layout
layouts: [dynamic]core.Page_Layout
defer delete(layouts)
append(&layouts, core.Page_Layout{
page_number = 1,
pattern_id = "single-splash",
width = 800,
height = 1200,
})
controller.state.page_layouts = layouts[:]
defer delete(controller.state.page_layouts)
msg := gui.action_regenerate_page_layout(&controller, 0)
testing.expect(t, msg == "Page has no panels", "empty layout should return error")
}
@test
gui_action_add_bubble_creates_map :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
testing.expect(t, controller.state.speech_bubbles == nil, "speech_bubbles should start nil")
msg := gui.action_add_bubble(&controller, "test_panel")
testing.expect(t, msg == "Added bubble", "add bubble should succeed")
testing.expect(t, controller.state.speech_bubbles != nil, "speech_bubbles map should be created")
slice, ok := controller.state.speech_bubbles["test_panel"]
testing.expect(t, ok, "panel key should exist in map")
testing.expect(t, len(slice) == 1, "should have one bubble")
testing.expect(t, slice[0].type == .Normal, "new bubble should be Normal type")
testing.expect(t, slice[0].text == "New bubble", "new bubble should have default text")
// Clean up owned strings
core.dispose_speech_bubbles(&controller.state.speech_bubbles)
}
@test
gui_action_delete_bubble_edge_cases :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
// Delete from nil map
msg := gui.action_delete_bubble(&controller, "panel_1", 0)
testing.expect(t, strings.has_prefix(msg, "No bubbles"), "delete from nil map should return error")
// Delete with invalid index on populated map
bubbles: [dynamic]core.Speech_Bubble
defer delete(bubbles)
bubble := core.Speech_Bubble{id = "b1", panel_id = "panel_1", type = .Normal, text = "hello"}
append(&bubbles, bubble)
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
controller.state.speech_bubbles["panel_1"] = bubbles[:]
msg3 := gui.action_delete_bubble(&controller, "panel_1", -1)
testing.expect(t, strings.has_prefix(msg3, "Bubble not found"), "negative index should return error")
msg4 := gui.action_delete_bubble(&controller, "panel_1", 5)
testing.expect(t, strings.has_prefix(msg4, "Bubble not found"), "out-of-range index should return error")
}
@test
gui_action_delete_bubble_removes_last_bubble :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
bubbles: [dynamic]core.Speech_Bubble
bubble := core.Speech_Bubble{id = "b1", panel_id = "panel_1", type = .Normal, text = "hello"}
append(&bubbles, bubble)
controller.state.speech_bubbles["panel_1"] = bubbles[:]
msg := gui.action_delete_bubble(&controller, "panel_1", 0)
testing.expect(t, msg == "Deleted bubble", "delete should succeed")
_, ok := controller.state.speech_bubbles["panel_1"]
testing.expect(t, !ok, "panel key should be removed when last bubble deleted")
// Clean up
core.dispose_speech_bubbles(&controller.state.speech_bubbles)
}
@test
gui_action_update_bubble_edge_cases :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
// Update from nil map
msg := gui.action_update_bubble(&controller, "panel_1", 0, .Thought, "")
testing.expect(t, strings.has_prefix(msg, "No bubbles"), "update from nil map should return error")
// Update with invalid index on populated map
bubbles: [dynamic]core.Speech_Bubble
defer delete(bubbles)
bubble := core.Speech_Bubble{id = "b1", panel_id = "panel_1", type = .Normal, text = "hello"}
append(&bubbles, bubble)
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
controller.state.speech_bubbles["panel_1"] = bubbles[:]
msg2 := gui.action_update_bubble(&controller, "panel_1", -1, .Thought, "")
testing.expect(t, strings.has_prefix(msg2, "Bubble not found"), "negative index should return error")
msg3 := gui.action_update_bubble(&controller, "panel_1", 5, .Thought, "")
testing.expect(t, strings.has_prefix(msg3, "Bubble not found"), "out-of-range index should return error")
}
@test
gui_action_update_bubble_changes_type :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
bubbles: [dynamic]core.Speech_Bubble
bubble := core.Speech_Bubble{id = "b1", panel_id = "panel_1", type = .Normal, text = "hello"}
append(&bubbles, bubble)
controller.state.speech_bubbles["panel_1"] = bubbles[:]
msg := gui.action_update_bubble(&controller, "panel_1", 0, .Thought, "")
testing.expect(t, msg == "Bubble updated", "update should succeed")
testing.expect(t, controller.state.speech_bubbles["panel_1"][0].type == .Thought, "bubble type should change")
core.dispose_speech_bubbles(&controller.state.speech_bubbles)
}
@test
gui_action_update_bubble_changes_text :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
controller := ui.new_controller(state)
defer ui.dispose_job_manager(&controller.jobs)
controller.state.speech_bubbles = make(map[string][]core.Speech_Bubble)
bubbles: [dynamic]core.Speech_Bubble
bubble := core.Speech_Bubble{id = "b1", panel_id = "panel_1", type = .Normal, text = "old text"}
append(&bubbles, bubble)
controller.state.speech_bubbles["panel_1"] = bubbles[:]
msg := gui.action_update_bubble(&controller, "panel_1", 0, .Normal, "new text")
testing.expect(t, msg == "Bubble updated", "update should succeed")
testing.expect(t, controller.state.speech_bubbles["panel_1"][0].text == "new text", "bubble text should change")
core.dispose_speech_bubbles(&controller.state.speech_bubbles)
}
// ─────────────────────────────────────────────────────────────
// Milestone 39C: Ownership/lifecycle audits for new GUI surfaces
// ─────────────────────────────────────────────────────────────
@test
gui_bubble_map_disposal_cleans_strings :: proc(t: ^testing.T) {
state := core.new_initial_state()
defer core.dispose_state(&state)
// Create bubbles with owned strings
state.speech_bubbles = make(map[string][]core.Speech_Bubble)
bubbles: [dynamic]core.Speech_Bubble
bubble := core.Speech_Bubble{
id = fmt.aprintf("bubble_%d", 1),
panel_id = fmt.aprintf("panel_%d", 1),
type = .Normal,
text = fmt.aprintf("Test dialogue %d", 1),
speaker_id = fmt.aprintf("char_%d", 1),
tail_direction = "bottom",
style = core.DEFAULT_BUBBLE_STYLE,
}
append(&bubbles, bubble)
state.speech_bubbles["panel_1"] = bubbles[:]
// Verify bubbles exist before dispose
testing.expect(t, len(state.speech_bubbles) == 1, "should have one panel entry")
testing.expect(t, len(state.speech_bubbles["panel_1"]) == 1, "should have one bubble")
// Dispose should clean up owned strings without errors
core.dispose_speech_bubbles(&state.speech_bubbles)
// Note: dispose_speech_bubbles deletes the map shell, so we don't check state after
testing.expect(t, true, "dispose completed without crash")
}
@test
gui_layout_page_cursor_clamp_stability :: proc(t: ^testing.T) {
testing.expect(t, gui.clamp_layout_cursor(0, 5) == 0, "zero layouts should return 0")
testing.expect(t, gui.clamp_layout_cursor(3, -1) == 0, "negative cursor should clamp to 0")
testing.expect(t, gui.clamp_layout_cursor(3, 3) == 2, "cursor at count should clamp to last")
testing.expect(t, gui.clamp_layout_cursor(5, 2) == 2, "valid cursor should pass through")
}
@test
gui_collect_layout_panels_for_page_edge_cases :: proc(t: ^testing.T) {
layouts: [dynamic]core.Page_Layout
defer delete(layouts)
// Empty layouts
result := gui.collect_layout_panels_for_page(layouts[:], 0)
testing.expect(t, result == nil, "empty layouts should return nil")
// Invalid page cursor
append(&layouts, core.Page_Layout{page_number = 1, pattern_id = "single-splash"})
result2 := gui.collect_layout_panels_for_page(layouts[:], -1)
testing.expect(t, result2 == nil, "negative cursor should return nil")
result3 := gui.collect_layout_panels_for_page(layouts[:], 5)
testing.expect(t, result3 == nil, "out-of-range cursor should return nil")
}
@test
gui_count_bubbles_for_panel_nil_map :: proc(t: ^testing.T) {
count := gui.count_bubbles_for_panel(nil, "panel_1")
testing.expect(t, count == 0, "nil map should return 0")
// Non-nil map with no entry
images := make(map[string][]core.Speech_Bubble)
defer core.dispose_speech_bubbles(&images)
count2 := gui.count_bubbles_for_panel(images, "panel_1")
testing.expect(t, count2 == 0, "missing key should return 0")
}