diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 8f8205e..8e59fa5 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -394,6 +394,9 @@ Known local toolchain state: - `pano_cli classify-open` exposes the `pp_app_core` document-open route contract as JSON and is covered for project files, ABR imports, PPBR imports, and malformed path rejection. +- `pano_cli plan-document-file` exposes `pp_app_core` document-name + validation, legacy `.ppi` path construction, and overwrite-prompt decisions + as JSON and is covered for save-now and existing-target overwrite states. - `pano_cli simulate-app-session` exposes `pp_app_core` project-open, app-close, save, save-as, save-version, and save-before-workflow decisions as JSON and is covered for clean, dirty, already-prompting, missing-canvas, @@ -402,9 +405,9 @@ Known local toolchain state: contract for PPI/project files, ABR imports, PPBR imports, inner-dot names, and malformed paths before the live `App::open_document` performs UI or legacy canvas work. -- `pp_app_core_document_session_tests` covers clean and dirty app session plus - save-request and save-before-workflow decisions without requiring a window, - canvas, or message box. +- `pp_app_core_document_session_tests` covers clean and dirty app session, + save-request, save-before-workflow, document file target, and overwrite + decisions without requiring a window, canvas, or message box. - `pp_ui_core` consumes vcpkg tinyxml2 only when `PP_USE_VCPKG_TINYXML2=ON` through the vcpkg preset; default and Android validation still use the retained vendored fallback tracked by DEBT-0012. diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index e1e19a0..da6429d 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -13,7 +13,7 @@ and validation command. | --- | --- | --- | --- | | PPI open/save | `Canvas`, serializer, dialogs | `pp_document`, `pp_assets`, `pano_cli` | Round-trip tiny project, old-version fixture, corrupt/truncated fixture | | Open-document routing | `App::open_document` | `pp_app_core`, `pano_cli`, `pp_panopainter_ui`, `pp_document`, `pp_assets` | Project/ABR/PPBR route tests, malformed path tests, CLI route smoke, app open smoke | -| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow decision tests, CLI session smoke, app close/open/save/new/browse smoke | +| Document session decisions | `App::open_document`, `App::request_close`, save hotkeys, file menu, dialogs | `pp_app_core`, `pano_cli`, `pp_panopainter_ui` | Clean/dirty/prompt-open/save/save-as/save-version/save-before-workflow/name/overwrite decision tests, CLI session and document-file smoke, app close/open/save/new/browse smoke | | Version metadata | `scripts/pre-build.py`, `version.*` | build system, `pp_foundation` | Generated header smoke test, missing-tag behavior | | Thumbnail generation/read | `Canvas`, `Image` | `pp_assets`, `pp_paint_renderer` | Golden thumbnail, corrupt input | | Save-as, overwrite prompts | App/dialogs | `pp_app_core`, `pp_panopainter_ui`, `pp_platform_*` | Decision tests, UI automation, and platform smoke | diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 3d81586..0c6fa9f 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -22,7 +22,7 @@ agent or engineer to remove them without reconstructing context from chat. | --- | --- | --- | --- | --- | --- | --- | | DEBT-0001 | Open | Modernization | Existing platform build files remain alongside new CMake | Required for incremental migration without losing platform coverage | Existing platform builds plus new CMake configure | Remove after all platform builds consume shared CMake targets | | DEBT-0002 | Open | Modernization | Vendored SDK and patched libraries retained initially | Some dependencies are SDK-only, patched, or have platform-specific binaries | Dependency inventory and platform build smoke tests | Replace with vcpkg packages or document permanent vendored status after triplet evaluation | -| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, `pano_cli classify-open`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session contracts, but document loading and saving still reach legacy `Canvas::I` and UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | +| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split; `App::open_document`, `App::request_close`, file-menu save actions, `NodeCanvas` save hotkeys, new/open/browse dirty-document workflow prompts, new/save-as document file naming and overwrite decisions, `pano_cli classify-open`, `pano_cli plan-document-file`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session contracts, but document loading and saving still reach legacy `Canvas::I` and UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Replace singleton reaches with context/service injection at component boundaries | | DEBT-0004 | Open | Modernization | Android, Linux, WebGL, Apple, and AppX build files remain platform-specific until root CMake alignment reaches them | Prevent platform regressions during incremental migration; raw Windows `.sln/.vcxproj` files were removed on 2026-05-31 by user decision | `cmake --preset windows-msvc-default`; platform-specific configure/build smoke checks as each platform is migrated | Root CMake owns every platform source list and package path | | DEBT-0005 | Open | Modernization | Temporary local CTest harness is used before Catch2 is wired through vcpkg | `vcpkg` is not currently on PATH, but headless tests need to run now | `ctest --preset desktop-fast --build-config Debug` | Replace `tests/test_harness.h` tests with Catch2 tests once vcpkg toolchain/presets are validated | | DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions | diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 2542227..31ad4c5 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -426,7 +426,9 @@ contract for project files, ABR imports, PPBR imports, and malformed path rejection. `pano_cli simulate-app-session` exposes the pure `pp_app_core` session decisions used by project-open, app-close, save, save-as, and save-version flows, plus the save-before-continue workflow gate used by -new-document/open/browse dialogs. +new-document/open/browse dialogs. `pano_cli plan-document-file` exposes the +same app-core document-name validation, legacy `.ppi` path construction, and +overwrite prompt decision used by new-document and save-as dialogs. `pano_cli parse-layout` exercises the XML layout path. Continue expanding document behavior toward legacy Canvas parity and then port OpenGL classes behind the renderer boundary. diff --git a/src/app_core/document_session.h b/src/app_core/document_session.h index 5974b5f..93ae102 100644 --- a/src/app_core/document_session.h +++ b/src/app_core/document_session.h @@ -1,5 +1,11 @@ #pragma once +#include "foundation/result.h" + +#include +#include +#include + namespace pp::app { enum class ProjectOpenDecision { @@ -33,6 +39,17 @@ enum class DocumentWorkflowDecision { prompt_save_before_continue, }; +enum class DocumentFileWriteDecision { + save_now, + prompt_overwrite, +}; + +struct DocumentFileTarget { + std::string name; + std::string directory; + std::string path; +}; + [[nodiscard]] constexpr ProjectOpenDecision plan_project_open(bool has_unsaved_changes) noexcept { return has_unsaved_changes @@ -97,4 +114,32 @@ enum class DocumentWorkflowDecision { : DocumentWorkflowDecision::continue_now; } +[[nodiscard]] inline pp::foundation::Result make_document_file_target( + std::string_view work_directory, + std::string_view document_name) +{ + if (document_name.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("document name must not be empty")); + } + + DocumentFileTarget target; + target.name = std::string(document_name); + target.directory = std::string(work_directory); + target.path.reserve(target.directory.size() + target.name.size() + 5); + target.path += target.directory; + target.path += "/"; + target.path += target.name; + target.path += ".ppi"; + return pp::foundation::Result::success(std::move(target)); +} + +[[nodiscard]] constexpr DocumentFileWriteDecision plan_document_file_write( + bool target_exists) noexcept +{ + return target_exists + ? DocumentFileWriteDecision::prompt_overwrite + : DocumentFileWriteDecision::save_now; +} + } diff --git a/src/app_dialogs.cpp b/src/app_dialogs.cpp index 0cd5696..6229893 100644 --- a/src/app_dialogs.cpp +++ b/src/app_dialogs.cpp @@ -157,21 +157,20 @@ void App::dialog_newdoc() dialog->btn_ok->on_click = [this, dialog](Node*) { std::string name = dialog->input->m_text; - std::string path = work_path + "/" + name + ".ppi"; - - if (name.empty()) + const auto target = pp::app::make_document_file_target(work_path, name); + if (!target) { message_box("Warning", "You need to specify a name to file."); return; } - auto action = [this, dialog, name, path] { + auto action = [this, dialog, target = target.value()] { std::array resolutions{ 512, 1024, 1536, 2048, 4096, 8192 }; int res = resolutions[dialog->m_resolution->m_current_index]; - doc_name = name; - doc_path = path; - doc_filename = name + ".ppi"; - doc_dir = work_path; + doc_name = target.name; + doc_path = target.path; + doc_filename = target.name + ".ppi"; + doc_dir = target.directory; layers->clear(); canvas->m_canvas->m_layers.clear(); @@ -189,7 +188,8 @@ void App::dialog_newdoc() App::I->hideKeyboard(); }; - if (Asset::exist(path)) + const auto write_decision = pp::app::plan_document_file_write(Asset::exist(target.value().path)); + if (write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite) { // ask confirm is file already exist auto msgbox = new NodeMessageBox(); @@ -363,32 +363,32 @@ void App::dialog_save() dialog->btn_ok->on_click = [this, dialog](Node*) { std::string name = dialog->input->m_text; - std::string path = work_path + "/" + name + ".ppi"; - - if (name.empty()) + const auto target = pp::app::make_document_file_target(work_path, name); + if (!target) { message_box("Warning", "You need to specify a name to file."); return; } - auto action = [this, dialog, name, path] { - canvas->m_canvas->project_save(path); - doc_name = name; - doc_path = path; - doc_dir = work_path; + auto action = [this, dialog, target = target.value()] { + canvas->m_canvas->project_save(target.path); + doc_name = target.name; + doc_path = target.path; + doc_dir = target.directory; title_update(); dialog->destroy(); App::I->hideKeyboard(); }; - if (Asset::exist(path)) + const auto write_decision = pp::app::plan_document_file_write(Asset::exist(target.value().path)); + if (write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite) { // ask confirm is file already exist auto msgbox = new NodeMessageBox(); msgbox->set_manager(&layout); msgbox->init(); msgbox->m_title->set_text("Warning"); - msgbox->m_message->set_text(("Are you sure you want to overwrite " + name + "?").c_str()); + msgbox->m_message->set_text(("Are you sure you want to overwrite " + target.value().name + "?").c_str()); msgbox->btn_ok->on_click = [this, msgbox, action](Node*) { action(); msgbox->destroy(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 381f16f..5697742 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -371,6 +371,18 @@ if(TARGET pano_cli) LABELS "app;integration;desktop-fast;fuzz" ) + add_test(NAME pano_cli_plan_document_file_save_now_smoke + COMMAND pano_cli plan-document-file --work-dir D:/Paint --name demo) + set_tests_properties(pano_cli_plan_document_file_save_now_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-document-file\".*\"name\":\"demo\".*\"directory\":\"D:/Paint\".*\"path\":\"D:/Paint/demo.ppi\".*\"exists\":false.*\"decision\":\"save-now\"") + + add_test(NAME pano_cli_plan_document_file_overwrite_smoke + COMMAND pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists) + set_tests_properties(pano_cli_plan_document_file_overwrite_smoke PROPERTIES + LABELS "app;integration;desktop-fast" + PASS_REGULAR_EXPRESSION "\"command\":\"plan-document-file\".*\"path\":\"D:/Paint/demo.ppi\".*\"exists\":true.*\"decision\":\"prompt-overwrite\"") + add_test(NAME pano_cli_simulate_app_session_clean_smoke COMMAND pano_cli simulate-app-session) set_tests_properties(pano_cli_simulate_app_session_clean_smoke PROPERTIES diff --git a/tests/app_core/document_session_tests.cpp b/tests/app_core/document_session_tests.cpp index 0db1587..e62f6a2 100644 --- a/tests/app_core/document_session_tests.cpp +++ b/tests/app_core/document_session_tests.cpp @@ -115,6 +115,34 @@ void workflow_with_dirty_canvas_prompts_for_save(pp::tests::Harness& harness) == pp::app::DocumentWorkflowDecision::prompt_save_before_continue); } +void document_file_target_rejects_empty_name(pp::tests::Harness& harness) +{ + const auto target = pp::app::make_document_file_target("D:/Paint", ""); + PP_EXPECT(harness, !target); + PP_EXPECT(harness, target.status().code == pp::foundation::StatusCode::invalid_argument); +} + +void document_file_target_builds_legacy_ppi_path(pp::tests::Harness& harness) +{ + const auto target = pp::app::make_document_file_target("D:/Paint", "demo"); + PP_EXPECT(harness, target); + PP_EXPECT(harness, target.value().name == "demo"); + PP_EXPECT(harness, target.value().directory == "D:/Paint"); + PP_EXPECT(harness, target.value().path == "D:/Paint/demo.ppi"); +} + +void document_file_write_prompts_only_for_existing_targets(pp::tests::Harness& harness) +{ + PP_EXPECT( + harness, + pp::app::plan_document_file_write(false) + == pp::app::DocumentFileWriteDecision::save_now); + PP_EXPECT( + harness, + pp::app::plan_document_file_write(true) + == pp::app::DocumentFileWriteDecision::prompt_overwrite); +} + } int main() @@ -131,5 +159,8 @@ int main() harness.run("workflow without canvas is unavailable", workflow_without_canvas_is_unavailable); harness.run("workflow with clean canvas continues now", workflow_with_clean_canvas_continues_now); harness.run("workflow with dirty canvas prompts for save", workflow_with_dirty_canvas_prompts_for_save); + harness.run("document file target rejects empty name", document_file_target_rejects_empty_name); + harness.run("document file target builds legacy ppi path", document_file_target_builds_legacy_ppi_path); + harness.run("document file write prompts only for existing targets", document_file_write_prompts_only_for_existing_targets); return harness.finish(); } diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index d973b2e..d897bfc 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -96,6 +96,12 @@ struct ClassifyOpenArgs { std::string path; }; +struct PlanDocumentFileArgs { + std::string work_directory; + std::string name; + bool target_exists = false; +}; + struct SimulateAppSessionArgs { bool has_canvas = true; bool new_document = false; @@ -313,6 +319,18 @@ const char* document_workflow_decision_name(pp::app::DocumentWorkflowDecision de return "unavailable"; } +const char* document_file_write_decision_name(pp::app::DocumentFileWriteDecision decision) noexcept +{ + switch (decision) { + case pp::app::DocumentFileWriteDecision::save_now: + return "save-now"; + case pp::app::DocumentFileWriteDecision::prompt_overwrite: + return "prompt-overwrite"; + } + + return "save-now"; +} + pp::foundation::Result parse_float_arg(std::string_view text) { float value = 0.0F; @@ -343,6 +361,7 @@ void print_help() << " import-image --path FILE [--document-width N] [--document-height N] [--face N] [--x N] [--y N]\n" << " inspect-project --path FILE\n" << " classify-open --path FILE\n" + << " plan-document-file --work-dir DIR --name NAME [--target-exists]\n" << " load-project --path FILE\n" << " parse-layout --path FILE\n" << " record-render [--width N] [--height N] [--exercise-clear]\n" @@ -1184,6 +1203,64 @@ int classify_open(int argc, char** argv) return 0; } +pp::foundation::Status parse_plan_document_file_args( + int argc, + char** argv, + PlanDocumentFileArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--work-dir") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + args.work_directory = argv[++i]; + } else if (key == "--name") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + args.name = argv[++i]; + } else if (key == "--target-exists") { + args.target_exists = true; + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + if (args.work_directory.empty()) { + return pp::foundation::Status::invalid_argument("work directory must not be empty"); + } + + return pp::foundation::Status::success(); +} + +int plan_document_file(int argc, char** argv) +{ + PlanDocumentFileArgs args; + const auto status = parse_plan_document_file_args(argc, argv, args); + if (!status.ok()) { + print_error("plan-document-file", status.message); + return 2; + } + + const auto target = pp::app::make_document_file_target(args.work_directory, args.name); + if (!target) { + print_error("plan-document-file", target.status().message); + return 2; + } + + const auto write_decision = pp::app::plan_document_file_write(args.target_exists); + std::cout << "{\"ok\":true,\"command\":\"plan-document-file\"" + << ",\"target\":{\"name\":\"" << json_escape(target.value().name) + << "\",\"directory\":\"" << json_escape(target.value().directory) + << "\",\"path\":\"" << json_escape(target.value().path) + << "\",\"exists\":" << json_bool(args.target_exists) + << "},\"decision\":\"" + << document_file_write_decision_name(write_decision) + << "\"}\n"; + return 0; +} + pp::foundation::Status parse_simulate_app_session_args( int argc, char** argv, @@ -3162,6 +3239,10 @@ int main(int argc, char** argv) return classify_open(argc, argv); } + if (command == "plan-document-file") { + return plan_document_file(argc, argv); + } + if (command == "load-project") { return load_project(argc, argv); }