check point

This commit is contained in:
dimitar committed 2026-05-21 06:10:32 +02:00
1 parent ae1cae967b
commit 1e85df5193
72 files changed
+11452

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
package tests
import "core:testing"
import "../src/adapters"
import "../src/core"
import "../src/shared"
deepseek_calls: int
phase2_deepseek_transport :: proc(cfg: shared.Config, request_json: string) -> (string, int, shared.App_Error) {
_ = cfg
_ = request_json
deepseek_calls += 1
if deepseek_calls == 1 {
return "", 429, shared.ok()
}
return "{\"choices\":[{\"message\":{\"content\":\"{\\\"title\\\":\\\"Test\\\",\\\"synopsis\\\":\\\"A hero rises\\\",\\\"characters\\\":[{\\\"id\\\":\\\"char_001\\\",\\\"name\\\":\\\"Hero\\\",\\\"role\\\":\\\"protagonist\\\",\\\"description\\\":\\\"Determined\\\",\\\"firstAppearancePanel\\\":\\\"panel_001_001\\\"}],\\\"pages\\\":[{\\\"pageNumber\\\":1,\\\"layoutType\\\":\\\"grid\\\",\\\"panels\\\":[{\\\"panelId\\\":\\\"panel_001_001\\\",\\\"panelNumber\\\":1,\\\"shotType\\\":\\\"medium\\\",\\\"description\\\":\\\"Hero stands\\\",\\\"charactersPresent\\\":[\\\"char_001\\\"],\\\"dialogue\\\":[{\\\"speakerId\\\":\\\"char_001\\\",\\\"text\\\":\\\"Let's go!\\\",\\\"bubbleType\\\":\\\"normal\\\",\\\"emotion\\\":\\\"determined\\\"}],\\\"caption\\\":\\\"\\\",\\\"soundEffects\\\":[],\\\"transitionFromPrevious\\\":\\\"none\\\"}]}]}\"}}]}", 200, shared.ok()
}
fal_calls: int
phase2_fal_transport :: proc(cfg: shared.Config, endpoint, prompt: string, seed: i64) -> (string, int, shared.App_Error) {
_ = cfg
_ = endpoint
_ = prompt
_ = seed
fal_calls += 1
if fal_calls == 1 {
return "", 0, shared.network_error("temporary network issue")
}
return "https://example.com/image.png", 200, shared.ok()
}
@test
deepseek_retries_then_succeeds :: proc(t: ^testing.T) {
deepseek_calls = 0
cfg := shared.Config{
deepseek_api_key = "test-key",
deepseek_base_url = "https://api.deepseek.com",
}
client := adapters.new_deepseek_client()
client.transport = phase2_deepseek_transport
client.max_retries = 3
opts := adapters.Generate_Script_Options{
story_idea = "A hero rises",
genre = "action",
art_style = "manga",
num_pages = 4,
audience = "general",
}
script, err := adapters.generate_comic_script(client, cfg, opts)
defer core.dispose_script_owned(&script)
testing.expect(t, shared.is_ok(err), "deepseek request should eventually succeed")
testing.expect(t, deepseek_calls == 2, "expected one retry before success")
testing.expect(t, len(script.pages) > 0, "generated script should contain pages")
}
@test
fal_queue_enforces_cap :: proc(t: ^testing.T) {
q := adapters.new_fal_queue(1)
first := adapters.try_acquire_slot(&q)
second := adapters.try_acquire_slot(&q)
adapters.release_slot(&q)
third := adapters.try_acquire_slot(&q)
testing.expect(t, first, "first slot acquisition should succeed")
testing.expect(t, !second, "second slot acquisition should fail when saturated")
testing.expect(t, third, "slot acquisition should succeed after release")
}
@test
fal_panel_generation_retries_network_error :: proc(t: ^testing.T) {
fal_calls = 0
cfg := shared.Config{fal_api_key = "test-key"}
q := adapters.new_fal_queue(2)
client := adapters.new_fal_client(&q)
client.transport = phase2_fal_transport
client.max_retries = 3
panel := core.Panel{panel_id = "panel_1", panel_number = 1, description = "Hero jumps over a gap"}
img, err := adapters.generate_panel_image(client, cfg, panel, nil, "manga", "proj_1")
defer delete(img.prompt)
testing.expect(t, shared.is_ok(err), "fal panel generation should eventually succeed")
testing.expect(t, fal_calls == 2, "expected one retry for network error")
testing.expect(t, len(img.url) > 0, "image url should be set")
}
@test
fal_typed_response_parsing :: proc(t: ^testing.T) {
body := "{\"images\":[{\"url\":\"https://example.com/a.png\",\"width\":1024,\"height\":1024}]}"
resp, err := adapters.fal_parse_response_body(body)
defer adapters.dispose_fal_response(&resp)
testing.expect(t, shared.is_ok(err), "typed parse should succeed")
testing.expect(t, len(resp.images) == 1, "expected one image")
testing.expect(t, resp.images[0].url == "https://example.com/a.png", "expected parsed URL")
}
+526
View File
@@ -0,0 +1,526 @@
package tests
import "core:fmt"
import "core:os"
import "core:strings"
import "core:testing"
import app "../src/app"
import "../src/core"
import "../src/shared"
import "../src/ui"
@test
cli_parse_commands :: proc(t: ^testing.T) {
c1 := app.parse_cli_command(nil)
testing.expect(t, c1.kind == .Demo, "no args should map to demo")
a2 := [1]string{"status"}
c2 := app.parse_cli_command(a2[:])
testing.expect(t, c2.kind == .Status, "status should parse")
a3 := [2]string{"save", "x.json"}
c3 := app.parse_cli_command(a3[:])
testing.expect(t, c3.kind == .Save, "save should parse")
testing.expect(t, c3.path == "x.json", "save path should parse")
a4 := [1]string{"tui"}
c4 := app.parse_cli_command(a4[:])
testing.expect(t, c4.kind == .Tui, "tui should parse")
a5 := [1]string{"gui"}
c5 := app.parse_cli_command(a5[:])
testing.expect(t, c5.kind == .Gui, "gui should parse")
testing.expect(t, app.normalize_tui_command("q") == "quit", "q alias should expand")
testing.expect(t, app.normalize_tui_command("1") == "goto story", "1 alias should map to story")
testing.expect(t, app.normalize_tui_command("?") == "doctor", "? alias should map to doctor")
testing.expect(t, app.normalize_tui_command("r") == "ready", "r alias should map to ready")
testing.expect(t, app.normalize_tui_command("n") == "next", "n alias should map to next")
testing.expect(t, app.normalize_tui_command("p") == "plan", "p alias should map to plan")
testing.expect(t, app.normalize_tui_command("x") == "auto", "x alias should map to auto")
pages, matched, perr := app.parse_generate_script_pages("generate script 6")
testing.expect(t, shared.is_ok(perr), "generate script pages parse should succeed")
testing.expect(t, matched, "generate script pages should match")
testing.expect(t, pages == 6, "generate script pages should parse value")
local_pages, lmatched, lerr := app.parse_generate_script_local_pages("generate script local 3")
testing.expect(t, shared.is_ok(lerr), "generate script local parse should succeed")
testing.expect(t, lmatched, "generate script local should match")
testing.expect(t, local_pages == 3, "generate script local should parse value")
page, pmatch, pperr := app.parse_generate_panels_page("generate panels page 2")
testing.expect(t, shared.is_ok(pperr), "generate panels page parse should succeed")
testing.expect(t, pmatch, "generate panels page should match")
testing.expect(t, page == 2, "generate panels page should parse value")
lpage, lpmatch, lperr := app.parse_generate_panels_local_page("generate panels local page 2")
testing.expect(t, shared.is_ok(lperr), "generate panels local page parse should succeed")
testing.expect(t, lpmatch, "generate panels local page should match")
testing.expect(t, lpage == 2, "generate panels local page should parse value")
fmt_kind, export_path, ematch, eerr := app.parse_export_command("export cbz ./out.cbz")
testing.expect(t, shared.is_ok(eerr), "export parse should succeed")
testing.expect(t, ematch, "export parse should match")
testing.expect(t, fmt_kind == .CBZ, "export format should parse")
testing.expect(t, export_path == "./out.cbz", "export path should parse")
qfmt, qpath, qpages, qmatch, qerr := app.parse_quick_local_command("quick local pdf ./quick.pdf 3")
testing.expect(t, shared.is_ok(qerr), "quick local parse should succeed")
testing.expect(t, qmatch, "quick local parse should match")
testing.expect(t, qfmt == .PDF, "quick local format should parse")
testing.expect(t, qpath == "./quick.pdf", "quick local path should parse")
testing.expect(t, qpages == 3, "quick local pages should parse")
proj, qafmt, qaout, qapages, qamatch, qaerr := app.parse_quick_local_all_command("quick local all ./p.comic.json cbz ./q.cbz 4")
testing.expect(t, shared.is_ok(qaerr), "quick local all parse should succeed")
testing.expect(t, qamatch, "quick local all parse should match")
testing.expect(t, proj == "./p.comic.json", "quick local all project path should parse")
testing.expect(t, qafmt == .CBZ, "quick local all format should parse")
testing.expect(t, qaout == "./q.cbz", "quick local all export path should parse")
testing.expect(t, qapages == 4, "quick local all pages should parse")
aafmt, aapath, aamatch, aaerr := app.parse_auto_all_command("auto all pdf ./auto.pdf")
testing.expect(t, shared.is_ok(aaerr), "auto all parse should succeed")
testing.expect(t, aamatch, "auto all parse should match")
testing.expect(t, aafmt == .PDF, "auto all format should parse")
testing.expect(t, aapath == "./auto.pdf", "auto all path should parse")
aalfmt, aalpath, aalpages, aalmatch, aalerr := app.parse_auto_all_local_command("auto all local cbz ./auto.cbz 3")
testing.expect(t, shared.is_ok(aalerr), "auto all local parse should succeed")
testing.expect(t, aalmatch, "auto all local parse should match")
testing.expect(t, aalfmt == .CBZ, "auto all local format should parse")
testing.expect(t, aalpath == "./auto.cbz", "auto all local path should parse")
testing.expect(t, aalpages == 3, "auto all local pages should parse")
}
@test
cli_save_and_load_roundtrip :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
path := fmt.aprintf("%s/project.comic.json", tmp_dir)
defer delete(path)
state := core.new_initial_state()
state.story_idea = "cli story"
save_out, save_err := app.run_cli_command(app.Parsed_CLI_Command{kind = .Save, path = path}, &state)
testing.expect(t, shared.is_ok(save_err), "save command should succeed")
testing.expect(t, strings.contains(save_out, "Saved project"), "save output should mention save")
delete(save_out)
state.story_idea = "changed"
load_out, load_err := app.run_cli_command(app.Parsed_CLI_Command{kind = .Load, path = path}, &state)
testing.expect(t, shared.is_ok(load_err), "load command should succeed")
testing.expect(t, strings.contains(load_out, "Loaded project"), "load output should mention load")
testing.expect(t, state.story_idea == "cli story", "load should restore story")
delete(load_out)
core.dispose_state_owned(&state)
}
@test
cli_tui_generate_script_requires_key :: proc(t: ^testing.T) {
prev := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator)
defer {
if len(prev) > 0 {
_ = os.set_env("DEEPSEEK_API_KEY", prev)
} else {
_ = os.unset_env("DEEPSEEK_API_KEY")
}
}
_ = os.unset_env("DEEPSEEK_API_KEY")
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "generate script 6", &last_job)
testing.expect(t, !shared.is_ok(err), "generate script should fail without configured key")
testing.expect(t, len(out) == 0, "error path should not return output")
}
@test
cli_tui_generate_panels_page_requires_script :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "generate panels page 2", &last_job)
testing.expect(t, !shared.is_ok(err), "generate panels page should fail with empty script")
testing.expect(t, len(out) == 0, "error path should not return output")
}
@test
cli_tui_generate_script_local_succeeds_without_key :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "generate script local 2", &last_job)
testing.expect(t, shared.is_ok(err), "generate script local should succeed without key")
testing.expect(t, strings.contains(out, "local script generated"), "local generation should return success message")
delete(out)
testing.expect(t, len(controller.state.script.pages) == 2, "local script should create requested pages")
}
@test
cli_tui_layout_and_export_require_data :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out1, err1 := app.run_tui_command(&controller, "layout auto", &last_job)
testing.expect(t, !shared.is_ok(err1), "layout auto should fail without script")
testing.expect(t, len(out1) == 0, "layout error path should not return output")
_, out2, err2 := app.run_tui_command(&controller, "export pdf ./tmp.pdf", &last_job)
testing.expect(t, !shared.is_ok(err2), "export should fail without layouts")
testing.expect(t, len(out2) == 0, "export error path should not return output")
}
@test
cli_tui_local_panels_and_export_pdf :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-local-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
out_pdf := fmt.aprintf("%s/local.pdf", tmp_dir)
defer delete(out_pdf)
state := core.new_initial_state()
controller := ui.new_controller(state)
defer {
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = nil
ui.dispose_controller(&controller)
}
last_job := 0
_, out1, err1 := app.run_tui_command(&controller, "generate script local 2", &last_job)
testing.expect(t, shared.is_ok(err1), "local script should succeed")
delete(out1)
_, out2, err2 := app.run_tui_command(&controller, "generate panels local", &last_job)
testing.expect(t, shared.is_ok(err2), "local panels should succeed")
delete(out2)
_, out3, err3 := app.run_tui_command(&controller, "layout auto", &last_job)
testing.expect(t, shared.is_ok(err3), "layout auto should succeed")
delete(out3)
export_cmd := fmt.aprintf("export pdf %s", out_pdf)
defer delete(export_cmd)
_, out4, err4 := app.run_tui_command(&controller, export_cmd, &last_job)
testing.expect(t, shared.is_ok(err4), "export should succeed")
delete(out4)
testing.expect(t, os.exists(out_pdf), "exported pdf should exist")
}
@test
cli_tui_quick_local_export_pdf :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-quick-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
out_pdf := fmt.aprintf("%s/quick.pdf", tmp_dir)
defer delete(out_pdf)
state := core.new_initial_state()
controller := ui.new_controller(state)
defer {
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = nil
ui.dispose_controller(&controller)
}
cmd := fmt.aprintf("quick local pdf %s 3", out_pdf)
defer delete(cmd)
last_job := 0
_, out, err := app.run_tui_command(&controller, cmd, &last_job)
testing.expect(t, shared.is_ok(err), "quick local should succeed")
testing.expect(t, strings.contains(out, "quick local exported"), "quick local should return success output")
delete(out)
testing.expect(t, len(controller.state.script.pages) == 3, "quick local should build requested page count")
testing.expect(t, os.exists(out_pdf), "quick-local exported pdf should exist")
}
@test
cli_tui_quick_local_all_saves_and_exports :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-quick-all-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/project.comic.json", tmp_dir)
export_path := fmt.aprintf("%s/quick.cbz", tmp_dir)
defer delete(project_path)
defer delete(export_path)
state := core.new_initial_state()
controller := ui.new_controller(state)
defer {
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = nil
ui.dispose_controller(&controller)
}
cmd := fmt.aprintf("quick local all %s cbz %s 2", project_path, export_path)
defer delete(cmd)
last_job := 0
_, out, err := app.run_tui_command(&controller, cmd, &last_job)
testing.expect(t, shared.is_ok(err), "quick local all should succeed")
testing.expect(t, strings.contains(out, "quick local all saved"), "quick local all should return success output")
delete(out)
testing.expect(t, os.exists(project_path), "quick local all should save project")
testing.expect(t, os.exists(export_path), "quick local all should export artifact")
}
@test
cli_tui_doctor_reports_status :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "doctor", &last_job)
testing.expect(t, shared.is_ok(err), "doctor should succeed")
testing.expect(t, strings.contains(out, "Doctor"), "doctor output should include header")
testing.expect(t, strings.contains(out, "deepseek key:"), "doctor output should include deepseek key status")
testing.expect(t, strings.contains(out, "curl:"), "doctor output should include curl status")
delete(out)
}
@test
cli_tui_ready_reports_status :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "ready", &last_job)
testing.expect(t, shared.is_ok(err), "ready should succeed")
testing.expect(t, strings.contains(out, "Ready"), "ready output should include header")
testing.expect(t, strings.contains(out, "script generated:"), "ready output should include script status")
testing.expect(t, strings.contains(out, "export ready:"), "ready output should include export status")
delete(out)
}
@test
cli_tui_next_recommends_action :: proc(t: ^testing.T) {
prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator)
prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator)
defer {
if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") }
if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") }
}
_ = os.unset_env("DEEPSEEK_API_KEY")
_ = os.unset_env("FAL_API_KEY")
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out1, err1 := app.run_tui_command(&controller, "next", &last_job)
testing.expect(t, shared.is_ok(err1), "next should succeed")
testing.expect(t, strings.contains(out1, "generate script local"), "next should recommend local script generation")
delete(out1)
_, out2, err2 := app.run_tui_command(&controller, "generate script local 1", &last_job)
testing.expect(t, shared.is_ok(err2), "generate script local should succeed")
delete(out2)
_, out3, err3 := app.run_tui_command(&controller, "next", &last_job)
testing.expect(t, shared.is_ok(err3), "next should succeed after script")
testing.expect(t, strings.contains(out3, "generate panels local"), "next should recommend local panel generation")
delete(out3)
}
@test
cli_tui_plan_reports_progress :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out1, err1 := app.run_tui_command(&controller, "plan", &last_job)
testing.expect(t, shared.is_ok(err1), "plan should succeed")
testing.expect(t, strings.contains(out1, "Plan"), "plan output should include header")
testing.expect(t, strings.contains(out1, "1) Script"), "plan output should include script step")
delete(out1)
_, out2, err2 := app.run_tui_command(&controller, "generate script local 1", &last_job)
testing.expect(t, shared.is_ok(err2), "local script should succeed")
delete(out2)
_, out3, err3 := app.run_tui_command(&controller, "plan", &last_job)
testing.expect(t, shared.is_ok(err3), "plan should succeed after script")
testing.expect(t, strings.contains(out3, "next:"), "plan output should include next hint")
delete(out3)
}
@test
cli_tui_auto_runs_next_step :: proc(t: ^testing.T) {
prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator)
prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator)
defer {
if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") }
if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") }
}
_ = os.unset_env("DEEPSEEK_API_KEY")
_ = os.unset_env("FAL_API_KEY")
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
last_job := 0
_, out, err := app.run_tui_command(&controller, "auto", &last_job)
testing.expect(t, shared.is_ok(err), "auto should succeed")
testing.expect(t, strings.contains(out, "auto ran:"), "auto output should include executed command")
delete(out)
testing.expect(t, len(controller.state.script.pages) > 0, "auto should progress by generating script")
}
@test
cli_tui_auto_all_runs_to_export :: proc(t: ^testing.T) {
prev_deep := os.get_env("DEEPSEEK_API_KEY", context.temp_allocator)
prev_fal := os.get_env("FAL_API_KEY", context.temp_allocator)
defer {
if len(prev_deep) > 0 { _ = os.set_env("DEEPSEEK_API_KEY", prev_deep) } else { _ = os.unset_env("DEEPSEEK_API_KEY") }
if len(prev_fal) > 0 { _ = os.set_env("FAL_API_KEY", prev_fal) } else { _ = os.unset_env("FAL_API_KEY") }
}
_ = os.unset_env("DEEPSEEK_API_KEY")
_ = os.unset_env("FAL_API_KEY")
tmp_dir, terr := os.make_directory_temp("", "comic-cli-auto-all-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
out_pdf := fmt.aprintf("%s/auto_all.pdf", tmp_dir)
defer delete(out_pdf)
state := core.new_initial_state()
controller := ui.new_controller(state)
defer {
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = nil
ui.dispose_controller(&controller)
}
last_job := 0
cmd := fmt.aprintf("auto all pdf %s", out_pdf)
defer delete(cmd)
_, out, err := app.run_tui_command(&controller, cmd, &last_job)
testing.expect(t, shared.is_ok(err), "auto all should succeed")
testing.expect(t, strings.contains(out, "auto all exported"), "auto all output should include success")
delete(out)
testing.expect(t, os.exists(out_pdf), "auto all should produce export file")
}
@test
cli_tui_auto_all_local_runs_to_export :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-auto-all-local-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
out_cbz := fmt.aprintf("%s/auto_all_local.cbz", tmp_dir)
defer delete(out_cbz)
state := core.new_initial_state()
controller := ui.new_controller(state)
defer {
for _, img in controller.state.panel_images {
delete(img.url)
delete(img.prompt)
}
delete(controller.state.panel_images)
controller.state.panel_images = nil
ui.dispose_controller(&controller)
}
last_job := 0
cmd := fmt.aprintf("auto all local cbz %s 2", out_cbz)
defer delete(cmd)
_, out, err := app.run_tui_command(&controller, cmd, &last_job)
testing.expect(t, shared.is_ok(err), "auto all local should succeed")
testing.expect(t, strings.contains(out, "auto all local exported"), "auto all local output should include success")
delete(out)
testing.expect(t, os.exists(out_cbz), "auto all local should produce export file")
}
@test
cli_tui_open_and_saveas_aliases :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-cli-alias-*", context.temp_allocator)
if terr != nil {
testing.expect(t, false, "failed to create temp dir")
return
}
defer os.remove_all(tmp_dir)
path := fmt.aprintf("%s/alias.comic.json", tmp_dir)
defer delete(path)
state := core.new_initial_state()
controller := ui.new_controller(state)
controller.state.story_idea = "alias story"
last_job := 0
save_cmd := fmt.aprintf("saveas %s", path)
defer delete(save_cmd)
_, out1, err1 := app.run_tui_command(&controller, save_cmd, &last_job)
testing.expect(t, shared.is_ok(err1), "saveas should succeed")
delete(out1)
controller.state.story_idea = "changed"
open_cmd := fmt.aprintf("open %s", path)
defer delete(open_cmd)
_, out2, err2 := app.run_tui_command(&controller, open_cmd, &last_job)
testing.expect(t, shared.is_ok(err2), "open should succeed")
delete(out2)
testing.expect(t, controller.state.story_idea == "alias story", "open should restore saved state")
if shared.is_ok(err2) {
ui.dispose_controller_owned(&controller)
} else {
ui.dispose_controller(&controller)
}
}
+84
View File
@@ -0,0 +1,84 @@
package tests
import "core:testing"
import "../src/core"
@test
seed_is_deterministic :: proc(t: ^testing.T) {
a := core.generate_panel_seed("proj_a", 1, 2, "panel_001")
b := core.generate_panel_seed("proj_a", 1, 2, "panel_001")
c := core.generate_panel_seed("proj_a", 1, 3, "panel_001")
testing.expect(t, a == b, "seed should be stable for same input")
testing.expect(t, a != c, "seed should change for different panel number")
}
@test
layout_packs_panels_into_pages :: proc(t: ^testing.T) {
panels_arr := [5]core.Panel{
{panel_id = "p1", panel_number = 1},
{panel_id = "p2", panel_number = 2},
{panel_id = "p3", panel_number = 3},
{panel_id = "p4", panel_number = 4},
{panel_id = "p5", panel_number = 5},
}
layouts := core.auto_layout_pages(panels_arr[:], .A4, "action", "grid-2x2")
defer {
for l in layouts {
delete(l.panels)
}
delete(layouts)
}
testing.expect(t, len(layouts) == 1, "5 panels should fit action-dynamic in a single page")
testing.expect(t, layouts[0].pattern_id == "action-dynamic", "expected smallest suitable action pattern")
testing.expect(t, len(layouts[0].panels) == 5, "single page should contain all panels")
}
@test
bubble_autoplacement_creates_entries :: proc(t: ^testing.T) {
dialogue_arr := [1]core.Dialogue{
{speaker_id = "char_1", text = "Hello there", bubble_type = .Normal, emotion = "neutral"},
}
chars_arr := [1]string{"char_1"}
panel := core.Panel{
panel_id = "panel_1",
dialogue = dialogue_arr[:],
characters_present = chars_arr[:],
caption = "Narration line",
}
bubbles := core.auto_place_panel_bubbles(panel, 800, 600)
defer {
for b in bubbles {
delete(b.id)
}
delete(bubbles)
}
testing.expect(t, len(bubbles) == 2, "dialogue + caption should produce 2 bubbles")
}
@test
script_normalization_fills_ids :: proc(t: ^testing.T) {
panels_arr := [1]core.Panel{{description = "A scene"}}
pages_arr := [1]core.Page{{panels = panels_arr[:]}}
chars_arr := [1]core.Character{{name = "Hero"}}
raw := core.Comic_Script{
title = "",
synopsis = "",
characters = chars_arr[:],
pages = pages_arr[:],
}
norm := core.normalize_script(raw)
defer {
delete(norm.title)
delete(norm.synopsis)
delete(norm.characters[0].id)
delete(norm.pages[0].panels[0].panel_id)
}
testing.expect(t, len(norm.title) > 0, "title should be filled")
testing.expect(t, len(norm.characters[0].id) > 0, "character id should be filled")
testing.expect(t, len(norm.pages[0].panels[0].panel_id) > 0, "panel id should be filled")
}
+10
View File
@@ -0,0 +1,10 @@
package tests
import "core:testing"
import "../src/core"
@test
state_initial_step :: proc(t: ^testing.T) {
state := core.new_initial_state()
testing.expect(t, state.workflow.current_step == .Story_Input, "expected Story_Input initial step")
}
+96
View File
@@ -0,0 +1,96 @@
package tests
import "core:fmt"
import "core:os"
import "core:strings"
import "core:testing"
import "../src/adapters"
import "../src/core"
import "../src/shared"
join2 :: proc(a, b: string) -> string {
return fmt.aprintf("%s/%s", a, b)
}
dispose_export_fixture :: proc(layouts: ^[]core.Page_Layout, panel_images: ^map[string]core.Panel_Image) {
for _, img in panel_images^ {
delete(img.url)
}
delete(panel_images^)
for l in layouts^ {
delete(l.panels)
}
delete(layouts^)
}
setup_export_fixture :: proc(t: ^testing.T) -> (tmp_dir: string, layouts: []core.Page_Layout, panel_images: map[string]core.Panel_Image) {
err: os.Error
tmp_dir, err = os.make_directory_temp("", "comic-export-test-*", context.temp_allocator)
if err != nil {
testing.expect(t, false, "failed to create temp dir")
return "", nil, nil
}
src_img := join2(tmp_dir, "src_panel.png")
img_bytes := []byte{137, 80, 78, 71, 13, 10, 26, 10}
werr := os.write_entire_file(src_img, img_bytes)
testing.expect(t, werr == nil, "failed to write source image")
panel_id := "panel_1"
layout_panel := core.Page_Layout_Panel{panel_id = panel_id, panel_number = 1, layout_cell = core.Layout_Cell{x = 0, y = 0, w = 1, h = 1}}
layout_panels_dyn: [dynamic]core.Page_Layout_Panel
append(&layout_panels_dyn, layout_panel)
layout := core.Page_Layout{page_number = 1, pattern_id = "grid-2x2", panels = layout_panels_dyn[:], width = 1000, height = 1400}
layouts_dyn: [dynamic]core.Page_Layout
append(&layouts_dyn, layout)
panel_images = make(map[string]core.Panel_Image)
panel_images[panel_id] = core.Panel_Image{url = fmt.aprintf("file://%s", src_img), width = 100, height = 100, seed = 1, prompt = ""}
delete(src_img)
return tmp_dir, layouts_dyn[:], panel_images
}
@test
export_png_and_cbz_create_files :: proc(t: ^testing.T) {
tmp_dir, layouts, panel_images := setup_export_fixture(t)
defer os.remove_all(tmp_dir)
defer dispose_export_fixture(&layouts, &panel_images)
png_out := join2(tmp_dir, "out_png.zip")
defer delete(png_out)
cbz_out := join2(tmp_dir, "out_cbz.cbz")
defer delete(cbz_out)
png_err := adapters.export_comic(png_out, layouts, panel_images, adapters.Export_Options{format = .PNG, page_size = .A4, dpi = 300, quality = 90})
testing.expect(t, shared.is_ok(png_err), "png export failed")
testing.expect(t, os.exists(png_out), "png archive output should exist")
png_data, prerr := os.read_entire_file(png_out, context.temp_allocator)
testing.expect(t, prerr == nil, "failed reading png zip")
testing.expect(t, len(png_data) >= 2 && string(png_data[:2]) == "PK", "png export should be a zip archive")
cbz_err := adapters.export_comic(cbz_out, layouts, panel_images, adapters.Export_Options{format = .CBZ, page_size = .A4, dpi = 300, quality = 90})
testing.expect(t, shared.is_ok(cbz_err), "cbz export failed")
testing.expect(t, os.exists(cbz_out), "cbz output should exist")
cbz_data, crerr := os.read_entire_file(cbz_out, context.temp_allocator)
testing.expect(t, crerr == nil, "failed reading cbz")
testing.expect(t, len(cbz_data) >= 2 && string(cbz_data[:2]) == "PK", "cbz should be a zip archive")
}
@test
export_pdf_creates_pdf_file :: proc(t: ^testing.T) {
tmp_dir, layouts, panel_images := setup_export_fixture(t)
defer os.remove_all(tmp_dir)
defer dispose_export_fixture(&layouts, &panel_images)
pdf_out := join2(tmp_dir, "out.pdf")
defer delete(pdf_out)
pdf_err := adapters.export_comic(pdf_out, layouts, panel_images, adapters.Export_Options{format = .PDF, page_size = .A4, dpi = 300, quality = 90})
testing.expect(t, shared.is_ok(pdf_err), "pdf export failed")
testing.expect(t, os.exists(pdf_out), "pdf output should exist")
data, rerr := os.read_entire_file(pdf_out, context.temp_allocator)
testing.expect(t, rerr == nil, "failed to read pdf output")
testing.expect(t, strings.has_prefix(string(data), "%PDF-"), "pdf output should start with %PDF-")
}
+695
View File
@@ -0,0 +1,695 @@
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"
@test
gui_parse_autosave_interval_bounds :: proc(t: ^testing.T) {
testing.expect(t, gui.parse_autosave_interval("42", 20) == 42, "valid interval should parse")
testing.expect(t, gui.parse_autosave_interval("", 20) == 20, "empty interval should fallback to default")
testing.expect(t, gui.parse_autosave_interval("1", 20) == 5, "interval should clamp to min")
testing.expect(t, gui.parse_autosave_interval("999", 20) == 300, "interval should clamp to max")
}
@test
gui_fit_text_for_width_truncates_with_ellipsis :: proc(t: ^testing.T) {
truncated := gui.fit_text_for_width("ABCDE", 1, 10)
testing.expect(t, truncated == "ABC…", "fit_text_for_width should enforce 4-char minimum width rule and truncate with ellipsis")
}
@test
gui_fit_text_for_width_passthrough_cases :: proc(t: ^testing.T) {
testing.expect(t, gui.fit_text_for_width("ABCD", 1, 10) == "ABCD", "text at min width should pass through")
testing.expect(t, gui.fit_text_for_width("ABCDE", 80, 0) == "ABCDE", "non-positive px_per_char should bypass truncation")
}
@test
gui_set_autosave_interval_text_clamps_and_formats :: proc(t: ^testing.T) {
interval_text := ""
msg := gui.set_autosave_interval_text(&interval_text, 3)
defer delete(interval_text)
defer delete(msg)
testing.expect(t, interval_text == "5", "text should clamp to minimum interval")
testing.expect(t, strings.contains(msg, "5s"), "message should include clamped seconds")
}
@test
gui_log_view_toggle_helpers :: proc(t: ^testing.T) {
lines: i32 = 6
msg1 := gui.toggle_log_lines_with_message(&lines)
defer delete(msg1)
testing.expect(t, lines == 4, "log lines should toggle from 6 to 4")
testing.expect(t, msg1 == "Log lines: 4", "log lines message should match")
msg2 := gui.toggle_log_lines_with_message(&lines)
defer delete(msg2)
testing.expect(t, lines == 6, "log lines should toggle back to 6")
testing.expect(t, msg2 == "Log lines: 6", "log lines message should match")
oldest_first := false
msg3 := gui.toggle_log_order_with_message(&oldest_first)
defer delete(msg3)
testing.expect(t, oldest_first, "log order should toggle to oldest-first")
testing.expect(t, strings.contains(msg3, "oldest"), "log order message should mention oldest")
msg4 := gui.toggle_log_order_with_message(&oldest_first)
defer delete(msg4)
testing.expect(t, !oldest_first, "log order should toggle back to newest-first")
testing.expect(t, strings.contains(msg4, "newest"), "log order message should mention newest")
}
@test
gui_export_format_helper_updates_path_and_dirty :: proc(t: ^testing.T) {
export_format: core.Export_Format = .PDF
export_path := "./comic.pdf"
is_dirty := false
msg := gui.set_export_format_with_message(&export_format, &export_path, .CBZ, &is_dirty)
defer delete(export_path)
defer delete(msg)
testing.expect(t, export_format == .CBZ, "format should update to CBZ")
testing.expect(t, is_dirty, "format switch should mark state dirty")
testing.expect(t, strings.has_suffix(export_path, ".cbz"), "export path should match selected format")
testing.expect(t, strings.has_prefix(msg, "Export format: CBZ"), "status should include format label")
}
@test
gui_diagnostics_context_builder_maps_fields :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
action_log: gui.Action_Log
ctx := gui.make_diagnostics_action_context(&controller, &action_log, true, false, true, false, 30, "./p.comic.json", "./e.cbz", 4, true)
testing.expect(t, ctx.controller == &controller, "context should hold controller pointer")
testing.expect(t, ctx.action_log == &action_log, "context should hold action-log pointer")
testing.expect(t, ctx.is_dirty, "context should map dirty flag")
testing.expect(t, !ctx.autosave_enabled, "context should map autosave flag")
testing.expect(t, ctx.project_ok && !ctx.export_ok, "context should map path health flags")
testing.expect(t, ctx.autosave_secs == 30, "context should map autosave seconds")
testing.expect(t, ctx.project_path == "./p.comic.json" && ctx.export_path == "./e.cbz", "context should map paths")
testing.expect(t, ctx.log_show_lines == 4 && ctx.log_oldest_first, "context should map log settings")
}
@test
gui_project_path_normalization_variants :: proc(t: ^testing.T) {
p1 := gui.normalize_project_path("")
testing.expect(t, p1 == "./gui_project.comic.json", "empty path should use default project path")
p2 := gui.normalize_project_path("my_story")
defer delete(p2)
testing.expect(t, p2 == "my_story.comic.json", "bare name should append .comic.json")
p3 := gui.normalize_project_path("my_story.json")
defer delete(p3)
testing.expect(t, p3 == "my_story.comic.json", "json path should be converted to .comic.json")
p4 := gui.normalize_project_path("already.comic.json")
testing.expect(t, p4 == "already.comic.json", "already-normalized path should remain unchanged")
}
@test
gui_fix_all_paths_normalizes_project_and_export :: proc(t: ^testing.T) {
project_path := "project"
export_path := "./out"
defer delete(project_path)
defer delete(export_path)
gui.fix_all_paths(&project_path, &export_path, .PNG)
testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "fix_all_paths should normalize project suffix")
testing.expect(t, strings.has_suffix(export_path, ".zip"), "PNG export should normalize to .zip suffix")
}
@test
gui_summary_toggle_supported_screen_guard :: proc(t: ^testing.T) {
opts := gui.Summary_View_Options{}
msg1 := gui.toggle_summary_show_if_supported(.Story, &opts)
testing.expect(t, len(msg1) == 0, "show toggle should no-op on unsupported screen")
msg2 := gui.toggle_summary_sort_if_supported(.Story, &opts)
testing.expect(t, len(msg2) == 0, "sort toggle should no-op on unsupported screen")
msg3 := gui.toggle_summary_show_if_supported(.Script, &opts)
defer delete(msg3)
testing.expect(t, strings.has_prefix(msg3, "Script summary show-all"), "show toggle should produce script status message")
}
@test
gui_push_dirty_status_sets_dirty_and_logs :: proc(t: ^testing.T) {
is_dirty := false
status_msg := fmt.aprintf("")
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
defer delete(status_msg)
gui.push_dirty_status(&is_dirty, &status_msg, &action_log, "Changed value")
testing.expect(t, is_dirty, "push_dirty_status should set dirty flag")
testing.expect(t, status_msg == "Changed value", "push_dirty_status should set status message")
testing.expect(t, action_log.count == 1, "push_dirty_status should append one action-log entry")
}
@test
gui_push_status_if_nonempty_only_pushes_for_content :: proc(t: ^testing.T) {
status_msg := fmt.aprintf("initial")
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
defer delete(status_msg)
gui.push_status_if_nonempty(&status_msg, &action_log, "")
testing.expect(t, status_msg == "initial", "empty optional status should not overwrite status message")
testing.expect(t, action_log.count == 0, "empty optional status should not push to action log")
gui.push_status_if_nonempty(&status_msg, &action_log, "non-empty")
testing.expect(t, status_msg == "non-empty", "non-empty optional status should update status message")
testing.expect(t, action_log.count == 1, "non-empty optional status should append one action-log entry")
}
@test
gui_confirmation_request_sets_overlay_state :: proc(t: ^testing.T) {
show_confirm_overlay := false
show_help_overlay := true
pending_action: gui.Pending_Confirm_Action = .None
msg := gui.request_confirmation(&show_confirm_overlay, &show_help_overlay, &pending_action, .Open_Project, "Confirm open?")
testing.expect(t, msg == "Confirm open?", "request_confirmation should return prompt message")
testing.expect(t, show_confirm_overlay, "request_confirmation should enable confirm overlay")
testing.expect(t, !show_help_overlay, "request_confirmation should close help overlay")
testing.expect(t, pending_action == .Open_Project, "request_confirmation should set pending action")
}
@test
gui_autosave_tick_noop_when_disabled_or_clean :: proc(t: ^testing.T) {
state := core.new_initial_state()
project_path := "./tmp-test.comic.json"
is_dirty := true
last_autosave_at: f64 = 0
last_save_at: f64 = -1
msg1 := gui.autosave_tick_with_message(&project_path, state, false, &is_dirty, &last_autosave_at, &last_save_at, 1)
testing.expect(t, len(msg1) == 0, "autosave tick should no-op when autosave is disabled")
testing.expect(t, is_dirty, "autosave disabled should not mutate dirty flag")
is_dirty = false
msg2 := gui.autosave_tick_with_message(&project_path, state, true, &is_dirty, &last_autosave_at, &last_save_at, 1)
testing.expect(t, len(msg2) == 0, "autosave tick should no-op when state is clean")
}
@test
gui_log_clear_and_reset_helpers :: proc(t: ^testing.T) {
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
status_msg := fmt.aprintf("")
defer delete(status_msg)
gui.push_status(&status_msg, &action_log, "first")
gui.push_status(&status_msg, &action_log, "second")
testing.expect(t, action_log.count == 2, "setup should add log entries")
clear_msg := gui.clear_action_log_with_message(&action_log)
testing.expect(t, clear_msg == "Action log cleared", "clear helper should return expected message")
testing.expect(t, action_log.count == 0, "clear helper should reset action log count")
lines: i32 = 4
oldest_first := true
reset_msg := gui.reset_log_view_with_message(&lines, &oldest_first)
testing.expect(t, reset_msg == "Reset log view", "reset helper should return expected message")
testing.expect(t, lines == 6, "reset helper should restore default line count")
testing.expect(t, !oldest_first, "reset helper should restore newest-first ordering")
}
@test
gui_confirm_resolve_none_returns_message :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
project_path := "./p.comic.json"
export_path := "./e.pdf"
is_dirty := false
last_autosave_at: f64 = 0
msg := gui.resolve_confirm_action_with_message(.None, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at)
testing.expect(t, msg == "No pending destructive action", "resolve helper should report none-pending message")
}
@test
gui_help_overlay_toggle_and_close_helpers :: proc(t: ^testing.T) {
show_help_overlay := false
gui.toggle_help_overlay(&show_help_overlay)
testing.expect(t, show_help_overlay, "toggle helper should enable help overlay")
gui.close_help_overlay_if_open(&show_help_overlay)
testing.expect(t, !show_help_overlay, "close helper should disable help overlay when open")
gui.close_help_overlay_if_open(&show_help_overlay)
testing.expect(t, !show_help_overlay, "close helper should be a no-op when already closed")
}
@test
gui_export_path_preset_and_derivation_helpers :: proc(t: ^testing.T) {
export_path := ""
msg1 := gui.set_export_preset_with_message(&export_path, .PNG)
defer delete(msg1)
testing.expect(t, strings.has_suffix(export_path, ".zip"), "preset helper should set PNG-compatible export path")
testing.expect(t, strings.has_prefix(msg1, "Preset export path:"), "preset helper should return status message")
delete(export_path)
project_path := "./work/my.comic.json"
msg2 := gui.set_export_path_from_project_with_message(&export_path, project_path, .CBZ)
defer delete(export_path)
defer delete(msg2)
testing.expect(t, strings.has_suffix(export_path, ".cbz"), "project-derivation helper should set CBZ-compatible export path")
testing.expect(t, strings.contains(msg2, "Export path from project dir:"), "project-derivation helper should return status message")
msg3 := gui.set_project_path_from_export_with_message(&project_path, export_path)
defer delete(project_path)
defer delete(msg3)
testing.expect(t, strings.has_suffix(project_path, "gui_project.comic.json"), "export-derivation helper should set project filename")
testing.expect(t, strings.contains(msg3, "Project path from export dir:"), "export-derivation helper should return status message")
}
@test
gui_reset_project_session_resets_dirty_and_screen :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.story_idea = "changed"
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
controller.active_screen = .Export
is_dirty := true
last_autosave_at: f64 = 123
msg := gui.reset_project_session(&controller, &is_dirty, &last_autosave_at, false)
testing.expect(t, msg == "Reset project", "reset helper should return expected status")
testing.expect(t, !is_dirty, "reset helper should clear dirty flag")
testing.expect(t, controller.active_screen == .Story, "reset helper should sync active screen to initial workflow")
testing.expect(t, last_autosave_at == 123, "reset helper should not touch autosave timestamp when touch_time is false")
}
@test
gui_toggle_autosave_with_message_flips_state :: proc(t: ^testing.T) {
autosave_enabled := true
msg1 := gui.toggle_autosave_with_message(&autosave_enabled)
defer delete(msg1)
testing.expect(t, !autosave_enabled, "autosave toggle should flip from enabled to disabled")
testing.expect(t, strings.contains(msg1, "no"), "autosave toggle message should report disabled state")
msg2 := gui.toggle_autosave_with_message(&autosave_enabled)
defer delete(msg2)
testing.expect(t, autosave_enabled, "autosave toggle should flip back to enabled")
testing.expect(t, strings.contains(msg2, "yes"), "autosave toggle message should report enabled state")
}
@test
gui_reset_helper_fields_with_message_sets_defaults :: proc(t: ^testing.T) {
export_path := "./custom.cbz"
local_script_pages := "9"
autosave_interval_text := "90"
defer delete(export_path)
msg := gui.reset_helper_fields_with_message(&export_path, &local_script_pages, &autosave_interval_text, .PDF)
testing.expect(t, msg == "Reset helper fields to defaults", "reset helpers should return expected status")
testing.expect(t, export_path == "./comic.pdf", "reset helpers should restore format-based export default")
testing.expect(t, local_script_pages == "2", "reset helpers should restore default local script pages")
testing.expect(t, autosave_interval_text == "20", "reset helpers should restore default autosave interval text")
}
@test
gui_clear_selected_field_with_message_behaviors :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.story_idea = "idea"
export_path := "./comic.pdf"
local_script_pages := "3"
project_path := "./p.comic.json"
autosave_interval_text := "30"
is_dirty := false
msg1 := gui.clear_selected_field_with_message(0, &state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty)
testing.expect(t, msg1 == "Cleared selected field", "clear helper should report clear when field had content")
testing.expect(t, len(state.story_idea) == 0, "clear helper should clear selected text field")
testing.expect(t, is_dirty, "clear helper should mark dirty when a field was cleared")
is_dirty = false
msg2 := gui.clear_selected_field_with_message(0, &state, &export_path, &local_script_pages, &project_path, &autosave_interval_text, &is_dirty)
testing.expect(t, msg2 == "Selected field already empty", "clear helper should report already-empty state")
testing.expect(t, !is_dirty, "clear helper should not mark dirty when nothing changed")
}
@test
gui_path_health_hint_variants :: proc(t: ^testing.T) {
testing.expect(t, gui.path_health_hint(true, true) == "", "path-health hint should be empty when both paths are healthy")
testing.expect(t, strings.contains(gui.path_health_hint(false, false), "Fix paths"), "path-health hint should mention fixing both paths")
testing.expect(t, strings.contains(gui.path_health_hint(false, true), "Fix project path"), "path-health hint should mention project path fix")
testing.expect(t, strings.contains(gui.path_health_hint(true, false), "Fix export path"), "path-health hint should mention export path fix")
}
@test
gui_path_health_predicates :: proc(t: ^testing.T) {
testing.expect(t, gui.project_path_is_normalized("./x.comic.json"), "project path predicate should accept normalized path")
testing.expect(t, !gui.project_path_is_normalized("./x.json"), "project path predicate should reject non-comic suffix")
testing.expect(t, gui.export_path_matches_format("./x.pdf", .PDF), "export path predicate should accept PDF suffix")
testing.expect(t, !gui.export_path_matches_format("./x.cbz", .PDF), "export path predicate should reject wrong format suffix")
}
@test
gui_screen_label_and_navigation_status :: proc(t: ^testing.T) {
testing.expect(t, gui.screen_status_label(.Panels) == "Panels", "screen label helper should map enum to name")
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
msg := gui.navigate_screen_with_status(&controller, .Story)
defer delete(msg)
testing.expect(t, msg == "Screen: Story", "navigate helper should return status message for successful navigation")
}
@test
gui_action_log_ring_buffer_retains_recent_entries :: proc(t: ^testing.T) {
status_msg := fmt.aprintf("")
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
defer delete(status_msg)
for i in 0..<10 {
msg := ""
switch i {
case 0: msg = "entry_00"
case 1: msg = "entry_01"
case 2: msg = "entry_02"
case 3: msg = "entry_03"
case 4: msg = "entry_04"
case 5: msg = "entry_05"
case 6: msg = "entry_06"
case 7: msg = "entry_07"
case 8: msg = "entry_08"
case 9: msg = "entry_09"
}
gui.push_status(&status_msg, &action_log, msg)
}
testing.expect(t, action_log.count == 10, "action log should track total pushes")
snapshot := gui.build_action_log_snapshot(action_log)
defer delete(snapshot)
testing.expect(t, strings.contains(snapshot, "entry_09"), "snapshot should include newest entry")
testing.expect(t, !strings.contains(snapshot, "entry_00"), "snapshot should drop entries outside ring capacity")
}
@test
gui_action_log_clear_resets_count_and_snapshot :: proc(t: ^testing.T) {
status_msg := fmt.aprintf("")
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
defer delete(status_msg)
gui.push_status(&status_msg, &action_log, "a")
gui.push_status(&status_msg, &action_log, "b")
testing.expect(t, action_log.count == 2, "setup should create action-log entries")
action_log.last_push_at = 42
_ = gui.clear_action_log_with_message(&action_log)
testing.expect(t, action_log.count == 0, "clear helper should reset count")
testing.expect(t, action_log.last_push_at == 0, "clear helper should reset last-push timestamp")
snapshot := gui.build_action_log_snapshot(action_log)
defer delete(snapshot)
testing.expect(t, snapshot == "(action log empty)", "snapshot should report empty log after clear")
}
@test
gui_set_status_replaces_owned_string :: proc(t: ^testing.T) {
status_msg := fmt.aprintf("start")
defer delete(status_msg)
gui.set_status(&status_msg, "updated")
testing.expect(t, status_msg == "updated", "set_status should replace existing status text")
}
@test
gui_open_project_session_missing_file_returns_error_and_keeps_state :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.story_idea = "original"
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
project_path := "./definitely_missing_project"
export_path := "./comic.pdf"
is_dirty := true
last_autosave_at: f64 = 777
msg := gui.open_project_session(&controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at)
defer delete(project_path)
testing.expect(t, strings.contains(msg, "project file does not exist"), "open helper should surface missing-file error")
testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "open helper should normalize missing-file project path")
testing.expect(t, controller.state.story_idea == "original", "failed open should keep existing controller state")
testing.expect(t, is_dirty, "failed open should preserve dirty flag")
testing.expect(t, last_autosave_at == 777, "failed open should preserve autosave timestamp")
}
@test
gui_resolve_confirm_action_reset_branch :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.story_idea = "before"
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
project_path := "./unused.comic.json"
export_path := "./unused.pdf"
is_dirty := true
last_autosave_at: f64 = 42
msg := gui.resolve_confirm_action_with_message(.Reset_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at)
testing.expect(t, msg == "Reset project", "resolve helper should dispatch reset action")
testing.expect(t, !is_dirty, "reset dispatch should clear dirty flag")
testing.expect(t, controller.active_screen == .Story, "reset dispatch should sync active screen")
testing.expect(t, last_autosave_at >= 0, "reset dispatch should set a non-negative autosave timestamp")
testing.expect(t, last_autosave_at != 42, "reset dispatch should refresh autosave timestamp")
}
@test
gui_open_project_session_success_updates_state_and_paths :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-open-*", context.temp_allocator)
testing.expect(t, terr == nil, "temp dir should be created")
if terr != nil {
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/session.comic.json", tmp_dir)
defer delete(project_path)
saved := core.new_initial_state()
saved.story_idea = "loaded-story"
err := adapters.save_project(project_path, saved)
testing.expect(t, shared.is_ok(err), "save_project should succeed for open-session test")
if !shared.is_ok(err) {
return
}
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller_owned(&controller)
controller.state.story_idea = "original-story"
export_path := "./placeholder.pdf"
is_dirty := true
last_autosave_at: f64 = 17
msg := gui.open_project_session(&controller, &project_path, &export_path, .CBZ, &is_dirty, &last_autosave_at)
defer delete(msg)
defer delete(export_path)
testing.expect(t, strings.has_prefix(msg, "Opened project:"), "open helper should return opened-project status")
testing.expect(t, controller.state.story_idea == "loaded-story", "open helper should replace controller state from loaded project")
testing.expect(t, !is_dirty, "open helper should clear dirty flag on success")
testing.expect(t, strings.has_suffix(export_path, ".cbz"), "open helper should sync export path suffix to selected format")
testing.expect(t, strings.contains(export_path, tmp_dir), "open helper should sync export path into the project directory")
testing.expect(t, last_autosave_at != 17, "open helper should refresh autosave timestamp on success")
}
@test
gui_resolve_confirm_action_open_branch_success :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-confirm-open-*", context.temp_allocator)
testing.expect(t, terr == nil, "temp dir should be created")
if terr != nil {
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/session.comic.json", tmp_dir)
defer delete(project_path)
saved := core.new_initial_state()
saved.story_idea = "confirm-open-loaded"
err := adapters.save_project(project_path, saved)
testing.expect(t, shared.is_ok(err), "save_project should succeed for resolve-open test")
if !shared.is_ok(err) {
return
}
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller_owned(&controller)
controller.state.story_idea = "before-open"
export_path := "./placeholder.pdf"
is_dirty := true
last_autosave_at: f64 = 99
msg := gui.resolve_confirm_action_with_message(.Open_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at)
defer delete(msg)
defer delete(export_path)
testing.expect(t, strings.has_prefix(msg, "Opened project:"), "resolve helper should dispatch open action")
testing.expect(t, controller.state.story_idea == "confirm-open-loaded", "resolve open dispatch should load target state")
testing.expect(t, !is_dirty, "resolve open dispatch should clear dirty flag")
testing.expect(t, strings.has_suffix(export_path, ".pdf"), "resolve open dispatch should sync export path to PDF suffix")
testing.expect(t, strings.contains(export_path, tmp_dir), "resolve open dispatch should sync export path into project directory")
testing.expect(t, last_autosave_at != 99, "resolve open dispatch should refresh autosave timestamp")
}
@test
gui_resolve_confirm_action_open_branch_missing_file_preserves_state :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.story_idea = "still-here"
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
project_path := "./missing-confirm-open"
export_path := "./out.pdf"
is_dirty := true
last_autosave_at: f64 = 55
msg := gui.resolve_confirm_action_with_message(.Open_Project, &controller, &project_path, &export_path, .PDF, &is_dirty, &last_autosave_at)
defer delete(project_path)
testing.expect(t, strings.contains(msg, "project file does not exist"), "resolve open branch should surface missing-file error")
testing.expect(t, controller.state.story_idea == "still-here", "resolve open failure should preserve existing state")
testing.expect(t, is_dirty, "resolve open failure should preserve dirty flag")
testing.expect(t, last_autosave_at == 55, "resolve open failure should preserve autosave timestamp")
}
@test
gui_autosave_tick_success_writes_project_and_clears_dirty :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-autosave-*", context.temp_allocator)
testing.expect(t, terr == nil, "temp dir should be created")
if terr != nil {
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/autosave_target.comic.json", tmp_dir)
defer delete(project_path)
state := core.new_initial_state()
is_dirty := true
last_autosave_at: f64 = -1
last_save_at: f64 = -1
msg := gui.autosave_tick_with_message(&project_path, state, true, &is_dirty, &last_autosave_at, &last_save_at, 0)
defer delete(msg)
testing.expect(t, strings.has_prefix(msg, "Autosaved:"), "autosave tick should report success when project write works")
testing.expect(t, strings.has_suffix(project_path, ".comic.json"), "autosave tick should normalize project path suffix")
testing.expect(t, os.exists(project_path), "autosave tick should write project file")
testing.expect(t, !is_dirty, "autosave tick should clear dirty flag after successful save")
testing.expect(t, last_save_at >= 0, "autosave tick should set last-save timestamp on success")
}
@test
gui_write_diagnostics_with_message_creates_file :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-diag-*", context.temp_allocator)
testing.expect(t, terr == nil, "temp dir should be created")
if terr != nil {
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/project.comic.json", tmp_dir)
defer delete(project_path)
export_path := fmt.aprintf("%s/out.pdf", tmp_dir)
defer delete(export_path)
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller(&controller)
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false)
msg := gui.write_diagnostics_with_message(ctx)
defer delete(msg)
testing.expect(t, strings.has_prefix(msg, "Wrote diagnostics file:"), "diagnostics write helper should report written file")
diag_path := fmt.aprintf("%s/gui_diagnostics.txt", tmp_dir)
defer delete(diag_path)
testing.expect(t, os.exists(diag_path), "diagnostics write helper should create diagnostics file")
}
@test
gui_write_session_report_with_message_creates_file :: proc(t: ^testing.T) {
tmp_dir, terr := os.make_directory_temp("", "comic-gui-report-*", context.temp_allocator)
testing.expect(t, terr == nil, "temp dir should be created")
if terr != nil {
return
}
defer os.remove_all(tmp_dir)
project_path := fmt.aprintf("%s/project.comic.json", tmp_dir)
defer delete(project_path)
export_path := fmt.aprintf("%s/out.cbz", tmp_dir)
defer delete(export_path)
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller(&controller)
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
status_msg := fmt.aprintf("")
defer delete(status_msg)
gui.push_status(&status_msg, &action_log, "seed log entry")
ctx := gui.make_diagnostics_action_context(&controller, &action_log, true, true, true, true, 30, project_path, export_path, 4, true)
msg := gui.write_session_report_with_message(ctx)
defer delete(msg)
testing.expect(t, strings.has_prefix(msg, "Wrote session report:"), "session report write helper should report written file")
report_path := fmt.aprintf("%s/gui_session_report.txt", tmp_dir)
defer delete(report_path)
testing.expect(t, os.exists(report_path), "session report helper should create report file")
}
@test
gui_write_diagnostics_with_message_failure_for_missing_dir :: proc(t: ^testing.T) {
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller(&controller)
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
project_path := "./missing-dir/sub/project.comic.json"
export_path := "./out.pdf"
ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false)
msg := gui.write_diagnostics_with_message(ctx)
defer delete(msg)
testing.expect(t, msg == "Failed writing diagnostics file", "diagnostics write helper should report failure for missing directory")
}
@test
gui_write_session_report_with_message_failure_for_missing_dir :: proc(t: ^testing.T) {
controller := ui.new_controller(core.new_initial_state())
defer ui.dispose_controller(&controller)
action_log: gui.Action_Log
defer gui.action_log_dispose(&action_log)
project_path := "./missing-dir/sub/project.comic.json"
export_path := "./out.pdf"
ctx := gui.make_diagnostics_action_context(&controller, &action_log, false, true, true, true, 20, project_path, export_path, 6, false)
msg := gui.write_session_report_with_message(ctx)
defer delete(msg)
testing.expect(t, msg == "Failed writing session report", "session report write helper should report failure for missing directory")
}
+58
View File
@@ -0,0 +1,58 @@
package tests
import "core:testing"
import "../src/core"
import "../src/ui"
@test
core_dispose_state_clears_collections :: proc(t: ^testing.T) {
state := core.new_initial_state()
sheet_urls_dyn: [dynamic]string
append(&sheet_urls_dyn, "x")
chars_dyn: [dynamic]core.Character
append(&chars_dyn, core.Character{name = "A", character_sheet_urls = sheet_urls_dyn[:]})
state.characters = chars_dyn[:]
state.panel_images = make(map[string]core.Panel_Image)
state.panel_images["p1"] = core.Panel_Image{url = "u"}
layout_panels_dyn: [dynamic]core.Page_Layout_Panel
append(&layout_panels_dyn, core.Page_Layout_Panel{panel_id = "p1"})
layouts_dyn: [dynamic]core.Page_Layout
append(&layouts_dyn, core.Page_Layout{panels = layout_panels_dyn[:]})
state.page_layouts = layouts_dyn[:]
state.speech_bubbles = make(map[string][]core.Speech_Bubble)
bubbles_dyn: [dynamic]core.Speech_Bubble
append(&bubbles_dyn, core.Speech_Bubble{id = "b1"})
state.speech_bubbles["p1"] = bubbles_dyn[:]
steps_dyn: [dynamic]core.Workflow_Step
append(&steps_dyn, core.Workflow_Step.Story_Input)
state.workflow.completed_steps = steps_dyn[:]
core.dispose_state(&state)
testing.expect(t, len(state.characters) == 0, "characters should be cleared")
testing.expect(t, len(state.panel_images) == 0, "panel_images should be cleared")
testing.expect(t, len(state.page_layouts) == 0, "page_layouts should be cleared")
testing.expect(t, len(state.speech_bubbles) == 0, "speech_bubbles should be cleared")
testing.expect(t, len(state.workflow.completed_steps) == 0, "completed_steps should be cleared")
}
@test
ui_dispose_controller_clears_jobs_and_state :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.panel_images = make(map[string]core.Panel_Image)
state.panel_images["p1"] = core.Panel_Image{url = "u"}
controller := ui.new_controller(state)
_ = ui.submit_job(&controller.jobs, .Generate_Script, "job")
ui.dispose_controller(&controller)
testing.expect(t, len(controller.jobs.jobs) == 0, "jobs should be cleared")
testing.expect(t, len(controller.state.panel_images) == 0, "state maps should be cleared")
}
+56
View File
@@ -0,0 +1,56 @@
package tests
import "core:fmt"
import "core:os"
import "core:testing"
import "../src/adapters"
import "../src/core"
import "../src/shared"
make_temp_project_path :: proc(t: ^testing.T) -> (string, string) {
tmp_dir, err := os.make_directory_temp("", "comic-odin-*", context.temp_allocator)
if err != nil {
testing.expect(t, false, "failed to create temp directory")
return "", ""
}
project_path := fmt.aprintf("%s/project.comic.json", tmp_dir)
return tmp_dir, project_path
}
@test
storage_save_load_roundtrip :: proc(t: ^testing.T) {
tmp_dir, project_path := make_temp_project_path(t)
defer os.remove_all(tmp_dir)
defer delete(project_path)
state := core.new_initial_state()
state.story_idea = "An inventor discovers a portal"
state.story_genre = "scifi"
state.user_mode = .Professional
err := adapters.save_project(project_path, state)
testing.expect(t, shared.is_ok(err), "save_project should succeed")
loaded, lerr := adapters.load_project(project_path)
defer core.dispose_state_owned(&loaded)
testing.expect(t, shared.is_ok(lerr), "load_project should succeed")
testing.expect(t, loaded.story_idea == state.story_idea, "story_idea should round-trip")
testing.expect(t, loaded.story_genre == state.story_genre, "story_genre should round-trip")
testing.expect(t, loaded.user_mode == state.user_mode, "user_mode should round-trip")
}
@test
storage_creates_asset_cache_dir :: proc(t: ^testing.T) {
tmp_dir, project_path := make_temp_project_path(t)
defer os.remove_all(tmp_dir)
defer delete(project_path)
state := core.new_initial_state()
err := adapters.save_project(project_path, state)
testing.expect(t, shared.is_ok(err), "save_project should succeed")
asset_dir, derr := adapters.derive_asset_cache_dir(project_path)
testing.expect(t, shared.is_ok(derr), "derive_asset_cache_dir should succeed")
testing.expect(t, os.exists(asset_dir), "asset cache directory should exist")
testing.expect(t, os.is_dir(asset_dir), "asset cache directory should be a directory")
}
+61
View File
@@ -0,0 +1,61 @@
package tests
import "core:testing"
import "../src/core"
import "../src/shared"
import "../src/ui"
@test
ui_navigation_guard_blocks_locked_screen :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
err := ui.navigate_to_screen(&controller, .Layout)
testing.expect(t, !shared.is_ok(err), "layout should be blocked before panels exist")
}
@test
ui_navigation_guard_allows_after_prereqs :: proc(t: ^testing.T) {
state := core.new_initial_state()
state.panel_images = make(map[string]core.Panel_Image)
state.panel_images["p1"] = core.Panel_Image{url = "https://example.com/p1.png", width = 1, height = 1}
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
err := ui.navigate_to_screen(&controller, .Layout)
testing.expect(t, shared.is_ok(err), "layout should be allowed once panel images exist")
}
@test
ui_background_job_lifecycle_and_cancel :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
job1 := ui.start_background_job(&controller, .Generate_Script, "job1")
testing.expect(t, controller.state.workflow.is_generating, "generation should be on after starting a job")
job2 := ui.start_background_job(&controller, .Generate_Panel, "job2")
_ = ui.mark_job_running(&controller.jobs, job1)
_ = ui.mark_job_running(&controller.jobs, job2)
cerr := ui.cancel_background_job(&controller, job2)
testing.expect(t, shared.is_ok(cerr), "cancel should succeed")
_ = ui.finish_background_job(&controller, job1, "")
testing.expect(t, !controller.state.workflow.is_generating, "generation should be off after active jobs are done")
}
@test
ui_workflow_transition_guard :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
err := ui.set_workflow_step(&controller, .Layout)
testing.expect(t, !shared.is_ok(err), "should block invalid direct transition")
err2 := ui.set_workflow_step(&controller, .Generating_Script)
testing.expect(t, shared.is_ok(err2), "should allow valid transition to generating script")
}
+45
View File
@@ -0,0 +1,45 @@
package tests
import "core:strings"
import "core:testing"
import "../src/core"
import "../src/shared"
import "../src/ui"
@test
ui_render_contains_header_and_status :: proc(t: ^testing.T) {
state := core.new_initial_state()
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
out := ui.render_app_to_string(controller)
defer delete(out)
testing.expect(t, strings.contains(out, "[comic-odin]"), "render should contain app header")
testing.expect(t, strings.contains(out, "jobs=0"), "render should show job count")
testing.expect(t, strings.contains(out, "Story"), "default screen should be story")
}
@test
ui_runtime_apply_commands_flow :: proc(t: ^testing.T) {
state := core.new_initial_state()
pages_dyn: [dynamic]core.Page
append(&pages_dyn, core.Page{page_number = 1})
chars_dyn: [dynamic]core.Character
append(&chars_dyn, core.Character{name = "A"})
state.script = core.Comic_Script{title = "Script", pages = pages_dyn[:], characters = chars_dyn[:]}
state.characters = chars_dyn[:]
state.panel_images = make(map[string]core.Panel_Image)
state.panel_images["p1"] = core.Panel_Image{url = "u", width = 1, height = 1}
controller := ui.new_controller(state)
defer ui.dispose_controller(&controller)
cmds := [2]ui.UI_Command{
{kind = .Navigate, screen = .Layout},
{kind = .Start_Generate, job_type = .Generate_Panel, message = "panel"},
}
err := ui.apply_commands(&controller, cmds[:])
testing.expect(t, shared.is_ok(err), "command sequence should succeed")
testing.expect(t, controller.active_screen == .Layout, "expected layout screen")
testing.expect(t, controller.state.workflow.is_generating, "generation should be active")
}