Centralize legacy document session bridge

This commit is contained in:
2026-06-04 13:30:22 +02:00
parent d980b81bd7
commit 4528edfb2c
10 changed files with 437 additions and 64 deletions

View File

@@ -86,6 +86,8 @@ set(PP_PANOPAINTER_APP_SOURCES
src/legacy_cloud_services.h
src/legacy_document_open_services.cpp
src/legacy_document_open_services.h
src/legacy_document_session_services.cpp
src/legacy_document_session_services.h
src/platform_legacy/legacy_platform_services.cpp
src/platform_legacy/legacy_platform_services.h
src/version.cpp

View File

@@ -584,14 +584,21 @@ Known local toolchain state:
progress-total decisions.
- `pp_app_core_document_session_tests` covers clean and dirty app session,
document-open action planning and executor dispatch/rejection, save-request,
save-before-workflow, new-document target/resolution/overwrite planning,
document file target, combined save-file overwrite planning, and save-version
target decisions without requiring a window, canvas, or message box.
close-request executor dispatch/no-op preservation, document-save executor
dispatch/no-op preservation, save-before-workflow executor dispatch,
new-document target/resolution/overwrite planning, document file target,
combined save-file overwrite planning, and save-version target decisions
without requiring a window, canvas, or message box.
- `src/legacy_document_open_services.*` is the current app-shell bridge between
`pp_app_core` document-open plans and live ABR/PPBR import prompts,
unsaved-project discard prompts, project opening, layer UI refresh, title
updates, and action-history clearing; remaining legacy execution ownership is
tracked by `DEBT-0039`.
- `src/legacy_document_session_services.*` is the current app-shell bridge
between `pp_app_core` document-session decisions and live close prompts,
save dialogs, save-version routing, existing-project saves, and
save-before-workflow prompts; retained legacy UI/canvas execution remains
tracked by `DEBT-0040`.
- `src/legacy_history_services.*` is the current app-shell bridge between
`pp_app_core` history plans and legacy `ActionManager`; toolbar and
`NodeCanvas` hotkeys share it while document-history extraction remains

View File

@@ -57,6 +57,7 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0037 | Open | Modernization | Recording lifecycle/export planning and execution dispatch now consume pure `pp_app_core` through `App::rec_start`, `App::rec_stop`, `App::rec_clear`, `App::rec_export`, `pano_cli plan-recording-session`, and the `RecordingServices` boundary; live execution is centralized in `src/legacy_recording_services.*`, but the bridge still owns legacy recording thread startup/shutdown, platform recorded-file cleanup, progress UI, PBO readback through `App::rec_loop`, and `MP4Encoder::write_mp4` execution | Preserve current timelapse/MP4 behavior while recording moves toward app/document/renderer/video services | `pp_app_core_document_recording_tests`; `pano_cli plan-recording-session --running --frame-count 12`; `pano_cli plan-recording-session --platform-clears-files`; `ctest --preset desktop-fast --build-config Debug` | Recording thread lifecycle, frame readback, platform cleanup, progress reporting, and MP4 writing are owned by injected app/renderer/video services with `App` methods acting only as adapters |
| DEBT-0038 | Open | Modernization | Cloud upload/browse/bulk planning and execution dispatch now consume pure `pp_app_core` through `App::cloud_upload`, `App::cloud_upload_all`, `App::cloud_browse`, `pano_cli plan-cloud-upload`, `pano_cli plan-cloud-upload-all`, `pano_cli plan-cloud-browse`, and the `CloudServices` boundary; live execution is centralized in `src/legacy_cloud_services.*`, but the bridge still uses legacy save-before-upload, `upload`/`download` network helpers, progress/message UI, OpenGL context guarding, `NodeDialogCloud`, `Canvas` project open, layer refresh, and `ActionManager` reset | Preserve current cloud behavior while cloud/network/document import flows move toward app/document/platform services | `pp_app_core_document_cloud_tests`; `pano_cli plan-cloud-upload --new-document --unsaved`; `pano_cli plan-cloud-browse --selected-file demo.ppi`; `pano_cli plan-cloud-upload-all --file-count 3`; `ctest --preset desktop-fast --build-config Debug` | Cloud upload/download, save-before-upload, progress reporting, cloud browse dialog, downloaded project opening, layer refresh, OpenGL context ownership, and action-history reset are owned by injected app/document/network/platform/renderer services with `App` methods acting only as adapters |
| DEBT-0039 | Open | Modernization | Document-open planning and execution dispatch now consume pure `pp_app_core` through `App::open_document`, `pano_cli plan-open-route`, `DocumentOpenServices`, and `src/legacy_document_open_services.*`, but the bridge still opens ABR/PPBR import prompts, launches legacy brush preset import threads, applies unsaved-project discard prompts, calls legacy project-open execution, refreshes layer UI, updates the app title, and clears legacy history directly | Preserve current file-open/import behavior while document loading and brush import move toward app/document/asset/UI services | `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli plan-open-route --path D:/Paint/Scenes/demo.ppi --unsaved`; `pano_cli plan-open-route --path D:/Paint/Brushes/clouds.ABR --unsaved`; `ctest --preset desktop-fast --build-config Debug` | Brush import prompting/execution, project-open execution, unsaved-project discard prompting, layer refresh, title updates, and history clearing are owned by injected app/document/asset/UI services with `App::open_document` acting only as an adapter |
| DEBT-0040 | Open | Modernization | Close request, document save, and save-before-workflow planning/execution dispatch now consume pure `pp_app_core` through `App::request_close`, `App::save_document`, `App::continue_document_workflow_after_optional_save`, `pano_cli simulate-app-session`, `DocumentSaveServices`, `CloseRequestServices`, `DocumentWorkflowServices`, and `src/legacy_document_session_services.*`, but the bridge still opens legacy message boxes/save dialogs, calls `Canvas::I->project_save`, mutates the unsaved flag on close confirmation, invokes native app close, and routes save-version through the retained legacy dialog | Preserve current close/save/dirty-workflow behavior while document session execution moves toward app/document/UI/platform services | `pp_app_core_document_session_tests`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `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`; `ctest --preset desktop-fast --build-config Debug` | Close prompt execution, native close requests, dirty-workflow save prompts, existing-project saves, save dialogs, save-version execution, and unsaved-flag mutation are owned by injected app/document/UI/platform services with `App` methods acting only as adapters |
## Closed Debt

View File

@@ -733,6 +733,12 @@ behind the renderer boundary.
unsaved-project discard prompts, project open, layer refresh, title updates,
and history clearing while those live effects remain tracked under
`DEBT-0039`.
`App::request_close`, `App::save_document`, and
`App::continue_document_workflow_after_optional_save` now route through
app-core document-session executors and `src/legacy_document_session_services.*`,
preserving close prompts, save dialogs, save-version routing, existing-project
save execution, and dirty-workflow save-before-continue prompts while retained
legacy UI/canvas behavior remains tracked under `DEBT-0040`.
Implementation tasks:
@@ -1272,6 +1278,15 @@ Results:
- Focused document-open CTest coverage passed for
`pp_app_core_document_route_tests`, `pp_app_core_document_session_tests`, and
the `pano_cli_plan_open_route_*` smoke tests after the live bridge split.
- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built
after close request, document save, and dirty-workflow continuation execution
moved behind document-session services. A clean rebuild was required once
because MSVC reported the known Debug PDB `LNK1103` corruption, after which
the build passed.
- Focused document-session CTest coverage passed for
`pp_app_core_document_session_tests`, `pano_cli_simulate_app_session_*`, and
`pano_cli_plan_document_file/version_*` smoke tests after the live bridge
split.
- `pp_app_core_document_recording_tests` passed, covering recording start/stop,
clear, platform recorded-file cleanup, frame-count reset, export progress
totals, and oversized progress-total clamping.

View File

@@ -11,6 +11,7 @@
#include "app_core/document_route.h"
#include "app_core/document_session.h"
#include "legacy_document_open_services.h"
#include "legacy_document_session_services.h"
#include "legacy_recording_services.h"
#include "platform_api/platform_services.h"
#include "renderer_gl/opengl_capabilities.h"
@@ -208,26 +209,14 @@ bool App::request_close()
const auto close_decision = pp::app::plan_close_request(
Canvas::I->m_unsaved,
dialog_already_opened);
if (close_decision == pp::app::CloseRequestDecision::close_now)
return true;
if (close_decision == pp::app::CloseRequestDecision::show_unsaved_prompt)
{
auto* m = layout[main_id]->add_child<NodeMessageBox>();
m->m_title->set_text("Unsaved document");
m->m_message->set_text("Do you want to close without saving?");
m->btn_ok->m_text->set_text("Yes");
m->btn_ok->on_click = [this](Node*) {
request_app_close();
Canvas::I->m_unsaved = false;
};
m->btn_cancel->m_text->set_text("No");
m->btn_cancel->on_click = [this,m](Node*) {
m->destroy();
dialog_already_opened = false;
};
dialog_already_opened = true;
}
return false;
const auto status = pp::panopainter::execute_legacy_close_request_decision(
*this,
close_decision,
dialog_already_opened);
if (!status.ok())
LOG("Close request action failed: %s", status.message);
return close_decision == pp::app::CloseRequestDecision::close_now;
}
void App::clear()

View File

@@ -65,6 +65,31 @@ public:
virtual void prompt_discard_unsaved_project(const DocumentOpenRoute& route) = 0;
};
class CloseRequestServices {
public:
virtual ~CloseRequestServices() = default;
virtual void request_close_now() = 0;
virtual void show_unsaved_close_prompt() = 0;
};
class DocumentSaveServices {
public:
virtual ~DocumentSaveServices() = default;
virtual void show_save_dialog() = 0;
virtual void save_existing_document() = 0;
virtual void save_document_version() = 0;
};
class DocumentWorkflowServices {
public:
virtual ~DocumentWorkflowServices() = default;
virtual void continue_workflow_now() = 0;
virtual void prompt_save_before_continue() = 0;
};
struct DocumentFileTarget {
std::string name;
std::string directory;
@@ -160,6 +185,24 @@ struct NewDocumentPlan {
: CloseRequestDecision::show_unsaved_prompt;
}
[[nodiscard]] inline pp::foundation::Status execute_close_request_decision(
CloseRequestDecision decision,
CloseRequestServices& services)
{
switch (decision) {
case CloseRequestDecision::close_now:
services.request_close_now();
return pp::foundation::Status::success();
case CloseRequestDecision::show_unsaved_prompt:
services.show_unsaved_close_prompt();
return pp::foundation::Status::success();
case CloseRequestDecision::wait_for_existing_prompt:
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown close request decision");
}
[[nodiscard]] constexpr DocumentSaveDecision plan_document_save(
bool is_new_document,
bool has_unsaved_changes,
@@ -191,6 +234,27 @@ struct NewDocumentPlan {
return DocumentSaveDecision::no_op;
}
[[nodiscard]] inline pp::foundation::Status execute_document_save_decision(
DocumentSaveDecision decision,
DocumentSaveServices& services)
{
switch (decision) {
case DocumentSaveDecision::no_op:
return pp::foundation::Status::success();
case DocumentSaveDecision::show_save_dialog:
services.show_save_dialog();
return pp::foundation::Status::success();
case DocumentSaveDecision::save_existing:
services.save_existing_document();
return pp::foundation::Status::success();
case DocumentSaveDecision::save_version:
services.save_document_version();
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown document save decision");
}
[[nodiscard]] constexpr DocumentWorkflowDecision plan_document_workflow(
bool has_canvas,
bool has_unsaved_changes) noexcept
@@ -204,6 +268,24 @@ struct NewDocumentPlan {
: DocumentWorkflowDecision::continue_now;
}
[[nodiscard]] inline pp::foundation::Status execute_document_workflow_decision(
DocumentWorkflowDecision decision,
DocumentWorkflowServices& services)
{
switch (decision) {
case DocumentWorkflowDecision::unavailable:
return pp::foundation::Status::success();
case DocumentWorkflowDecision::continue_now:
services.continue_workflow_now();
return pp::foundation::Status::success();
case DocumentWorkflowDecision::prompt_save_before_continue:
services.prompt_save_before_continue();
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("unknown document workflow decision");
}
[[nodiscard]] inline pp::foundation::Result<DocumentFileTarget> make_document_file_target(
std::string_view work_directory,
std::string_view document_name)

View File

@@ -6,6 +6,7 @@
#include "app_core/document_session.h"
#include "legacy_document_canvas_services.h"
#include "legacy_document_layer_services.h"
#include "legacy_document_session_services.h"
#include "legacy_history_services.h"
#include "settings.h"
#include "node_dialog_open.h"
@@ -23,6 +24,8 @@
#define MP4V2_NO_STDINT_DEFS
#include <mp4v2/mp4v2.h>
#include <utility>
#ifdef __QUEST__
#include "oculus_vr.h"
#elif __WEB__
@@ -138,34 +141,12 @@ void App::continue_document_workflow_after_optional_save(std::function<void()> a
const bool has_canvas = canvas != nullptr;
const bool has_unsaved_changes = has_canvas && Canvas::I->m_unsaved;
const auto decision = pp::app::plan_document_workflow(has_canvas, has_unsaved_changes);
switch (decision) {
case pp::app::DocumentWorkflowDecision::unavailable:
return;
case pp::app::DocumentWorkflowDecision::continue_now:
action();
return;
case pp::app::DocumentWorkflowDecision::prompt_save_before_continue:
break;
}
auto m = layout[main_id]->add_child<NodeMessageBox>();
m->m_title->set_text("Unsaved document");
m->m_message->set_text("Would you like to save this document before closing?");
m->btn_ok->m_text->set_text("Yes");
m->btn_cancel->m_text->set_text("No");
m->btn_ok->on_click = [this, m, action](Node*) {
Canvas::I->project_save([this, m, action](bool success) {
if (success)
action();
else
message_box("Saving Error", "There was a problem saving the document");
});
m->destroy();
};
m->btn_cancel->on_click = [m, action](Node*) {
action();
m->destroy();
};
const auto status = pp::panopainter::execute_legacy_document_workflow_decision(
*this,
decision,
std::move(action));
if (!status.ok())
LOG("Document workflow action failed: %s", status.message);
}
void App::dialog_newdoc()
@@ -350,19 +331,9 @@ void App::save_document(pp::app::DocumentSaveIntent intent)
Canvas::I->m_newdoc,
Canvas::I->m_unsaved,
intent);
switch (decision) {
case pp::app::DocumentSaveDecision::show_save_dialog:
dialog_save();
break;
case pp::app::DocumentSaveDecision::save_existing:
Canvas::I->project_save();
break;
case pp::app::DocumentSaveDecision::save_version:
dialog_save_ver();
break;
case pp::app::DocumentSaveDecision::no_op:
break;
}
const auto status = pp::panopainter::execute_legacy_document_save_decision(*this, decision);
if (!status.ok())
LOG("Document save action failed: %s", status.message);
}
void App::dialog_save()

View File

@@ -0,0 +1,143 @@
#include "pch.h"
#include "legacy_document_session_services.h"
#include "app.h"
namespace pp::panopainter {
namespace {
class LegacyCloseRequestServices final : public pp::app::CloseRequestServices {
public:
LegacyCloseRequestServices(App& app, bool& dialog_already_opened) noexcept
: app_(app)
, dialog_already_opened_(dialog_already_opened)
{
}
void request_close_now() override
{
}
void show_unsaved_close_prompt() override
{
auto* app = &app_;
auto* dialog_already_opened = &dialog_already_opened_;
auto* m = app_.layout[app_.main_id]->add_child<NodeMessageBox>();
m->m_title->set_text("Unsaved document");
m->m_message->set_text("Do you want to close without saving?");
m->btn_ok->m_text->set_text("Yes");
m->btn_ok->on_click = [app](Node*) {
app->request_app_close();
Canvas::I->m_unsaved = false;
};
m->btn_cancel->m_text->set_text("No");
m->btn_cancel->on_click = [dialog_already_opened, m](Node*) {
m->destroy();
*dialog_already_opened = false;
};
dialog_already_opened_ = true;
}
private:
App& app_;
bool& dialog_already_opened_;
};
class LegacyDocumentSaveServices final : public pp::app::DocumentSaveServices {
public:
explicit LegacyDocumentSaveServices(App& app) noexcept
: app_(app)
{
}
void show_save_dialog() override
{
app_.dialog_save();
}
void save_existing_document() override
{
Canvas::I->project_save();
}
void save_document_version() override
{
app_.dialog_save_ver();
}
private:
App& app_;
};
class LegacyDocumentWorkflowServices final : public pp::app::DocumentWorkflowServices {
public:
LegacyDocumentWorkflowServices(App& app, std::function<void()> action) noexcept
: app_(app)
, action_(std::move(action))
{
}
void continue_workflow_now() override
{
action_();
}
void prompt_save_before_continue() override
{
auto m = app_.layout[app_.main_id]->add_child<NodeMessageBox>();
m->m_title->set_text("Unsaved document");
m->m_message->set_text("Would you like to save this document before closing?");
m->btn_ok->m_text->set_text("Yes");
m->btn_cancel->m_text->set_text("No");
auto* app = &app_;
auto action = action_;
m->btn_ok->on_click = [app, m, action](Node*) {
Canvas::I->project_save([app, m, action](bool success) {
if (success)
action();
else
app->message_box("Saving Error", "There was a problem saving the document");
});
m->destroy();
};
m->btn_cancel->on_click = [m, action](Node*) {
action();
m->destroy();
};
}
private:
App& app_;
std::function<void()> action_;
};
} // namespace
pp::foundation::Status execute_legacy_close_request_decision(
App& app,
pp::app::CloseRequestDecision decision,
bool& dialog_already_opened)
{
LegacyCloseRequestServices services(app, dialog_already_opened);
return pp::app::execute_close_request_decision(decision, services);
}
pp::foundation::Status execute_legacy_document_save_decision(
App& app,
pp::app::DocumentSaveDecision decision)
{
LegacyDocumentSaveServices services(app);
return pp::app::execute_document_save_decision(decision, services);
}
pp::foundation::Status execute_legacy_document_workflow_decision(
App& app,
pp::app::DocumentWorkflowDecision decision,
std::function<void()> action)
{
LegacyDocumentWorkflowServices services(app, std::move(action));
return pp::app::execute_document_workflow_decision(decision, services);
}
} // namespace pp::panopainter

View File

@@ -0,0 +1,26 @@
#pragma once
#include "app_core/document_session.h"
#include "foundation/result.h"
#include <functional>
class App;
namespace pp::panopainter {
[[nodiscard]] pp::foundation::Status execute_legacy_close_request_decision(
App& app,
pp::app::CloseRequestDecision decision,
bool& dialog_already_opened);
[[nodiscard]] pp::foundation::Status execute_legacy_document_save_decision(
App& app,
pp::app::DocumentSaveDecision decision);
[[nodiscard]] pp::foundation::Status execute_legacy_document_workflow_decision(
App& app,
pp::app::DocumentWorkflowDecision decision,
std::function<void()> action);
} // namespace pp::panopainter

View File

@@ -43,6 +43,70 @@ public:
std::string call_order;
};
class FakeCloseRequestServices final : public pp::app::CloseRequestServices {
public:
void request_close_now() override
{
close_now += 1;
call_order += "close;";
}
void show_unsaved_close_prompt() override
{
close_prompts += 1;
call_order += "prompt;";
}
int close_now = 0;
int close_prompts = 0;
std::string call_order;
};
class FakeDocumentSaveServices final : public pp::app::DocumentSaveServices {
public:
void show_save_dialog() override
{
dialogs += 1;
call_order += "dialog;";
}
void save_existing_document() override
{
saves += 1;
call_order += "save;";
}
void save_document_version() override
{
versions += 1;
call_order += "version;";
}
int dialogs = 0;
int saves = 0;
int versions = 0;
std::string call_order;
};
class FakeDocumentWorkflowServices final : public pp::app::DocumentWorkflowServices {
public:
void continue_workflow_now() override
{
continues += 1;
call_order += "continue;";
}
void prompt_save_before_continue() override
{
prompts += 1;
call_order += "prompt-save;";
}
int continues = 0;
int prompts = 0;
std::string call_order;
};
[[nodiscard]] pp::app::DocumentOpenRoute project_route()
{
return {
@@ -212,6 +276,25 @@ void close_dirty_document_opens_one_prompt(pp::tests::Harness& harness)
pp::app::plan_close_request(true, true) == pp::app::CloseRequestDecision::wait_for_existing_prompt);
}
void close_request_executor_dispatches_and_preserves_wait(pp::tests::Harness& harness)
{
FakeCloseRequestServices services;
PP_EXPECT(
harness,
pp::app::execute_close_request_decision(pp::app::CloseRequestDecision::close_now, services).ok());
PP_EXPECT(
harness,
pp::app::execute_close_request_decision(pp::app::CloseRequestDecision::show_unsaved_prompt, services).ok());
PP_EXPECT(
harness,
pp::app::execute_close_request_decision(pp::app::CloseRequestDecision::wait_for_existing_prompt, services).ok());
PP_EXPECT(harness, services.close_now == 1);
PP_EXPECT(harness, services.close_prompts == 1);
PP_EXPECT(harness, services.call_order == "close;prompt;");
}
void save_clean_existing_document_is_no_op(pp::tests::Harness& harness)
{
PP_EXPECT(
@@ -220,6 +303,29 @@ void save_clean_existing_document_is_no_op(pp::tests::Harness& harness)
== pp::app::DocumentSaveDecision::no_op);
}
void save_executor_dispatches_visible_work_and_no_ops_cleanly(pp::tests::Harness& harness)
{
FakeDocumentSaveServices services;
PP_EXPECT(
harness,
pp::app::execute_document_save_decision(pp::app::DocumentSaveDecision::show_save_dialog, services).ok());
PP_EXPECT(
harness,
pp::app::execute_document_save_decision(pp::app::DocumentSaveDecision::save_existing, services).ok());
PP_EXPECT(
harness,
pp::app::execute_document_save_decision(pp::app::DocumentSaveDecision::save_version, services).ok());
PP_EXPECT(
harness,
pp::app::execute_document_save_decision(pp::app::DocumentSaveDecision::no_op, services).ok());
PP_EXPECT(harness, services.dialogs == 1);
PP_EXPECT(harness, services.saves == 1);
PP_EXPECT(harness, services.versions == 1);
PP_EXPECT(harness, services.call_order == "dialog;save;version;");
}
void save_new_or_dirty_document_has_user_visible_work(pp::tests::Harness& harness)
{
PP_EXPECT(
@@ -292,6 +398,34 @@ void workflow_with_dirty_canvas_prompts_for_save(pp::tests::Harness& harness)
== pp::app::DocumentWorkflowDecision::prompt_save_before_continue);
}
void workflow_executor_dispatches_continue_prompt_and_unavailable(pp::tests::Harness& harness)
{
FakeDocumentWorkflowServices services;
PP_EXPECT(
harness,
pp::app::execute_document_workflow_decision(
pp::app::DocumentWorkflowDecision::continue_now,
services)
.ok());
PP_EXPECT(
harness,
pp::app::execute_document_workflow_decision(
pp::app::DocumentWorkflowDecision::prompt_save_before_continue,
services)
.ok());
PP_EXPECT(
harness,
pp::app::execute_document_workflow_decision(
pp::app::DocumentWorkflowDecision::unavailable,
services)
.ok());
PP_EXPECT(harness, services.continues == 1);
PP_EXPECT(harness, services.prompts == 1);
PP_EXPECT(harness, services.call_order == "continue;prompt-save;");
}
void document_file_target_rejects_empty_name(pp::tests::Harness& harness)
{
const auto target = pp::app::make_document_file_target("D:/Paint", "");
@@ -484,13 +618,16 @@ int main()
harness.run("document open executor rejects mismatched routes", document_open_executor_rejects_mismatched_routes);
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("close request executor dispatches and preserves wait", close_request_executor_dispatches_and_preserves_wait);
harness.run("save clean existing document is no op", save_clean_existing_document_is_no_op);
harness.run("save executor dispatches visible work and no ops cleanly", save_executor_dispatches_visible_work_and_no_ops_cleanly);
harness.run("save new or dirty document has user visible work", save_new_or_dirty_document_has_user_visible_work);
harness.run("save as always shows save dialog", save_as_always_shows_save_dialog);
harness.run("save version respects menu and hotkey behaviors", save_version_respects_menu_and_hotkey_behaviors);
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("workflow executor dispatches continue prompt and unavailable", workflow_executor_dispatches_continue_prompt_and_unavailable);
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);