Plan document open actions in app core

This commit is contained in:
2026-06-02 23:06:36 +02:00
parent 1df506a176
commit fd1772a417
9 changed files with 179 additions and 12 deletions

View File

@@ -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-open-route` exposes `pp_app_core` document-open action
planning as JSON and is covered for clean project open, dirty project
discard-prompt, and ABR import-prompt states.
- `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.
@@ -417,9 +420,9 @@ Known local toolchain state:
directory/stem targets, picked-directory stems, MP4 suggested names, and
invalid export naming inputs.
- `pp_app_core_document_session_tests` covers clean and dirty app session,
save-request, save-before-workflow, document file target, overwrite, and
save-version target decisions without requiring a window, canvas, or message
box.
document-open action planning, save-request, save-before-workflow, document
file target, overwrite, and save-version target 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.

View File

@@ -12,7 +12,7 @@ and validation command.
| Capability | Current Area | Target Owner | Required Tests |
| --- | --- | --- | --- |
| 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 |
| 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, open-action plan tests, CLI route/action 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/name/overwrite/version-target decision tests, CLI session, document-file, and document-version 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 |

View File

@@ -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, new/save-as document file naming and overwrite decisions, save-version target decisions, export target naming/path decisions, `pano_cli classify-open`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-target`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export contracts, but document loading, saving, and export execution 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_export_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 plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `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, save-version target decisions, export target naming/path decisions, `pano_cli classify-open`, `pano_cli plan-open-route`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `pano_cli plan-export-target`, and `pano_cli simulate-app-session` now consume pure `pp_app_core` route/session/export contracts, but document loading, brush import execution, saving, and export execution still reach legacy `Canvas::I`/UI singletons | Avoid behavior changes while introducing component boundaries | App launch and component tests; `pp_app_core_document_route_tests`; `pp_app_core_document_export_tests`; `pp_app_core_document_session_tests`; `pano_cli classify-open --path D:/Paint/demo.ppi`; `pano_cli plan-open-route --path D:/Paint/demo.ppi --unsaved`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli plan-export-target --kind file --work-dir D:/Paint --doc-name demo --extension .png`; `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 |

View File

@@ -423,9 +423,11 @@ the applied stroke payload survives inspect/load round-trip automation, with a
rejection smoke test for unsafe tiny canvas dimensions.
`pano_cli classify-open` exposes the pure `pp_app_core` document-open route
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
rejection. `pano_cli plan-open-route` exposes the pure `pp_app_core`
document-open action plan for clean project open, dirty project prompt, and
brush-import prompt flows. `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. `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.

View File

@@ -193,7 +193,10 @@ void App::open_document(std::string path)
if (!route)
return;
if (route.value().kind == pp::app::DocumentOpenKind::import_abr)
const bool has_unsaved_project =
route.value().kind == pp::app::DocumentOpenKind::open_project && Canvas::I->m_unsaved;
const auto open_plan = pp::app::plan_document_open(route.value().kind, has_unsaved_project);
if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_abr)
{
auto mb = message_box("Import ABR", "Would you like to import the brushes?", true);
mb->on_submit = [this, path] (Node* target) {
@@ -201,7 +204,7 @@ void App::open_document(std::string path)
target->destroy();
};
}
else if (route.value().kind == pp::app::DocumentOpenKind::import_ppbr)
else if (open_plan == pp::app::DocumentOpenPlanAction::prompt_import_ppbr)
{
auto mb = message_box("Import PPBR", "Would you like to import the brushes?", true);
mb->on_submit = [this, path] (Node* target) {
@@ -239,8 +242,7 @@ void App::open_document(std::string path)
});
ActionManager::clear();
};
const auto open_decision = pp::app::plan_project_open(Canvas::I->m_unsaved);
if (open_decision == pp::app::ProjectOpenDecision::open_now)
if (open_plan == pp::app::DocumentOpenPlanAction::open_project_now)
{
open_action();
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include "app_core/document_route.h"
#include "foundation/result.h"
#include <cctype>
@@ -46,6 +47,13 @@ enum class DocumentFileWriteDecision {
prompt_overwrite,
};
enum class DocumentOpenPlanAction {
open_project_now,
prompt_discard_unsaved_project,
prompt_import_abr,
prompt_import_ppbr,
};
struct DocumentFileTarget {
std::string name;
std::string directory;
@@ -64,6 +72,24 @@ struct DocumentVersionTarget {
: ProjectOpenDecision::open_now;
}
[[nodiscard]] constexpr DocumentOpenPlanAction plan_document_open(
DocumentOpenKind kind,
bool has_unsaved_changes) noexcept
{
switch (kind) {
case DocumentOpenKind::import_abr:
return DocumentOpenPlanAction::prompt_import_abr;
case DocumentOpenKind::import_ppbr:
return DocumentOpenPlanAction::prompt_import_ppbr;
case DocumentOpenKind::open_project:
return has_unsaved_changes
? DocumentOpenPlanAction::prompt_discard_unsaved_project
: DocumentOpenPlanAction::open_project_now;
}
return DocumentOpenPlanAction::open_project_now;
}
[[nodiscard]] constexpr CloseRequestDecision plan_close_request(
bool has_unsaved_changes,
bool close_prompt_already_open) noexcept

View File

@@ -381,6 +381,24 @@ if(TARGET pano_cli)
LABELS "app;integration;desktop-fast;fuzz"
)
add_test(NAME pano_cli_plan_open_route_project_clean_smoke
COMMAND pano_cli plan-open-route --path "D:/Paint/Scenes/demo.ppi")
set_tests_properties(pano_cli_plan_open_route_project_clean_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-open-route\".*\"kind\":\"open-project\".*\"unsaved\":false.*\"action\":\"open-project-now\"")
add_test(NAME pano_cli_plan_open_route_project_unsaved_smoke
COMMAND pano_cli plan-open-route --path "D:/Paint/Scenes/demo.ppi" --unsaved)
set_tests_properties(pano_cli_plan_open_route_project_unsaved_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-open-route\".*\"kind\":\"open-project\".*\"unsaved\":true.*\"action\":\"prompt-discard-unsaved-project\"")
add_test(NAME pano_cli_plan_open_route_abr_import_smoke
COMMAND pano_cli plan-open-route --path "D:/Paint/Brushes/clouds.ABR" --unsaved)
set_tests_properties(pano_cli_plan_open_route_abr_import_smoke PROPERTIES
LABELS "app;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"plan-open-route\".*\"kind\":\"import-abr\".*\"unsaved\":true.*\"action\":\"prompt-import-abr\"")
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

View File

@@ -15,6 +15,38 @@ void project_open_dirty_document_prompts_for_discard(pp::tests::Harness& harness
pp::app::plan_project_open(true) == pp::app::ProjectOpenDecision::prompt_discard_unsaved);
}
void document_open_project_respects_unsaved_state(pp::tests::Harness& harness)
{
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::open_project, false)
== pp::app::DocumentOpenPlanAction::open_project_now);
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::open_project, true)
== pp::app::DocumentOpenPlanAction::prompt_discard_unsaved_project);
}
void document_open_brush_imports_prompt_regardless_of_unsaved_state(pp::tests::Harness& harness)
{
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::import_abr, false)
== pp::app::DocumentOpenPlanAction::prompt_import_abr);
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::import_abr, true)
== pp::app::DocumentOpenPlanAction::prompt_import_abr);
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::import_ppbr, false)
== pp::app::DocumentOpenPlanAction::prompt_import_ppbr);
PP_EXPECT(
harness,
pp::app::plan_document_open(pp::app::DocumentOpenKind::import_ppbr, true)
== pp::app::DocumentOpenPlanAction::prompt_import_ppbr);
}
void close_clean_document_executes_immediately(pp::tests::Harness& harness)
{
PP_EXPECT(
@@ -206,6 +238,10 @@ int main()
pp::tests::Harness harness;
harness.run("project open clean document executes immediately", project_open_clean_document_executes_immediately);
harness.run("project open dirty document prompts for discard", project_open_dirty_document_prompts_for_discard);
harness.run("document open project respects unsaved state", document_open_project_respects_unsaved_state);
harness.run(
"document open brush imports prompt regardless of unsaved state",
document_open_brush_imports_prompt_regardless_of_unsaved_state);
harness.run("close clean document executes immediately", close_clean_document_executes_immediately);
harness.run("close dirty document opens one prompt", close_dirty_document_opens_one_prompt);
harness.run("save clean existing document is no op", save_clean_existing_document_is_no_op);

View File

@@ -97,6 +97,11 @@ struct ClassifyOpenArgs {
std::string path;
};
struct PlanOpenRouteArgs {
std::string path;
bool unsaved = false;
};
struct PlanDocumentFileArgs {
std::string work_directory;
std::string name;
@@ -275,6 +280,22 @@ const char* project_open_decision_name(pp::app::ProjectOpenDecision decision) no
return "open-now";
}
const char* document_open_plan_action_name(pp::app::DocumentOpenPlanAction action) noexcept
{
switch (action) {
case pp::app::DocumentOpenPlanAction::open_project_now:
return "open-project-now";
case pp::app::DocumentOpenPlanAction::prompt_discard_unsaved_project:
return "prompt-discard-unsaved-project";
case pp::app::DocumentOpenPlanAction::prompt_import_abr:
return "prompt-import-abr";
case pp::app::DocumentOpenPlanAction::prompt_import_ppbr:
return "prompt-import-ppbr";
}
return "open-project-now";
}
const char* close_request_decision_name(pp::app::CloseRequestDecision decision) noexcept
{
switch (decision) {
@@ -377,6 +398,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-open-route --path FILE [--unsaved]\n"
<< " plan-document-file --work-dir DIR --name NAME [--target-exists]\n"
<< " plan-document-version --directory DIR --doc-name NAME [--existing-path FILE]\n"
<< " plan-export-target --kind file|collection|stem|name --doc-name NAME [--work-dir DIR] [--directory DIR] [--extension EXT] [--suffix SUFFIX]\n"
@@ -1221,6 +1243,60 @@ int classify_open(int argc, char** argv)
return 0;
}
pp::foundation::Status parse_plan_open_route_args(
int argc,
char** argv,
PlanOpenRouteArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--path") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
args.path = argv[++i];
} else if (key == "--unsaved") {
args.unsaved = true;
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
if (args.path.empty()) {
return pp::foundation::Status::invalid_argument("path must not be empty");
}
return pp::foundation::Status::success();
}
int plan_open_route(int argc, char** argv)
{
PlanOpenRouteArgs args;
const auto status = parse_plan_open_route_args(argc, argv, args);
if (!status.ok()) {
print_error("plan-open-route", status.message);
return 2;
}
const auto route = pp::app::classify_document_open_path(args.path);
if (!route) {
print_error("plan-open-route", route.status().message);
return 2;
}
const auto action = pp::app::plan_document_open(route.value().kind, args.unsaved);
std::cout << "{\"ok\":true,\"command\":\"plan-open-route\""
<< ",\"route\":{\"kind\":\"" << document_open_kind_name(route.value().kind)
<< "\",\"path\":\"" << json_escape(route.value().path)
<< "\",\"directory\":\"" << json_escape(route.value().directory)
<< "\",\"name\":\"" << json_escape(route.value().name)
<< "\",\"extension\":\"" << json_escape(route.value().extension)
<< "\"},\"state\":{\"unsaved\":" << json_bool(args.unsaved)
<< "},\"plan\":{\"action\":\"" << document_open_plan_action_name(action)
<< "\"}}\n";
return 0;
}
pp::foundation::Status parse_plan_document_file_args(
int argc,
char** argv,
@@ -3475,6 +3551,10 @@ int main(int argc, char** argv)
return classify_open(argc, argv);
}
if (command == "plan-open-route") {
return plan_open_route(argc, argv);
}
if (command == "plan-document-file") {
return plan_document_file(argc, argv);
}