Start document model tests

This commit is contained in:
2026-06-01 00:02:42 +02:00
parent 8014345b99
commit 23eba07901
12 changed files with 352 additions and 15 deletions

View File

@@ -89,6 +89,19 @@ target_link_libraries(pp_paint
PRIVATE
pp_project_warnings)
add_library(pp_document STATIC
src/document/document.cpp)
target_include_directories(pp_document
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(pp_document
PUBLIC
pp_foundation
pp_paint
pp_project_options
PRIVATE
pp_project_warnings)
if(PP_BUILD_TOOLS)
add_subdirectory(tools/pano_cli)
endif()

View File

@@ -75,7 +75,8 @@ Known local toolchain state:
- Android NDK: `C:\Users\omara\AppData\Local\Android\Sdk\ndk\29.0.14206865`
- Android arm64 headless configure/build passes through root CMake and the
`platform-build` automation wrapper for `pp_foundation`, `pp_assets`,
`pp_paint`, `pano_cli`, and their current headless test binaries.
`pp_paint`, `pp_document`, `pano_cli`, and their current headless test
binaries.
- `vcpkg` is not on PATH yet; see DEBT-0007.
Known warnings after the current CMake app build:

View File

@@ -25,11 +25,13 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0003 | Open | Modernization | Existing singletons remain during initial split | Avoid behavior changes while introducing component boundaries | App launch and component tests | 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-0006 | Open | Modernization | `pano_cli create-document` validates and emits JSON command contracts but does not yet invoke the legacy document/app model | The document model has not been extracted from `Canvas`/`App` yet | `pano_cli create-document --width 64 --height 32 --layers 2`; CTest `pano_cli_create_document_smoke` | Replace command contract implementation with real `pp_document` creation once Phase 4 extracts the document model |
| DEBT-0007 | Open | Modernization | `vcpkg.json` exists but CMake is not yet using a validated vcpkg toolchain on this machine | `vcpkg` is not available on PATH and Visual Studio reports manifest mode is disabled | `cmake --preset windows-msvc-default` currently configures with vendored dependencies | Add validated vcpkg toolchain/preset integration for desktop, Android, and Apple triplets |
| DEBT-0008 | Open | Modernization | `windows-msvc-default` preset is used for local validation because the VS 2026 generator is not installed here | The target VS 2026 preset must remain, but this machine configures with Visual Studio 17 2022 | `cmake --preset windows-msvc-default`; `ctest --preset desktop-fast --build-config Debug` | Validate `windows-vs2026-x64` on a machine with Visual Studio 2026 installed and make it the default Windows validation preset |
| DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, not APK/package variants | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands |
| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/document model but is not yet wired to legacy `Canvas`, PPI load/save, animation frames, or undo/redo | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade |
## Closed Debt
None yet.
| ID | Status | Owner | Item | Reason | Validation | Removal Condition |
| --- | --- | --- | --- | --- | --- | --- |
| DEBT-0006 | Closed | Modernization | `pano_cli create-document` validates and emits JSON command contracts but does not yet invoke the legacy document/app model | The document model had not been extracted from `Canvas`/`App` yet | `ctest --preset desktop-fast --build-config Debug`; `pano_cli_create_document_smoke` | Closed on 2026-05-31: command now constructs a real `pp_document::CanvasDocument` |

View File

@@ -243,9 +243,9 @@ Goal: make each component reachable by automated tools and future agents.
Status: in progress. `tests/` exists, `desktop-fast` runs headlessly, and
PowerShell/bash wrappers exist for configure/build/test/analyze/platform-build.
`pano_cli` exists with JSON automation commands for validating create-document
inputs and inspecting image signatures; full document/app integration is
debt-tracked as DEBT-0006.
`pano_cli` exists with JSON automation commands for creating a `pp_document`
model and inspecting image signatures; full document/app integration is
debt-tracked as DEBT-0010.
Implementation tasks:
@@ -305,8 +305,9 @@ input. A deterministic `TraceRecorder` now records component/name/thread/frame
and stroke timing spans with invalid-end tests. `pp_assets` has started with
PNG/JPEG signature detection and corrupt/truncated/unsupported tests.
`pp_paint` has started with CPU reference math for the five current shader
blend modes. Continue extracting legacy-safe utilities before moving document
behavior.
blend modes. `pp_document` has started with a pure canvas/layer model and
layer invariant tests. Continue expanding document behavior toward legacy
Canvas parity.
Implementation tasks:
@@ -506,7 +507,7 @@ Last verified on 2026-05-31:
```powershell
cmake --preset windows-msvc-default
cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pano_cli PanoPainter
cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pp_document_tests pano_cli PanoPainter
ctest --preset desktop-fast --build-config Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\test.ps1 -Preset desktop-fast -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli
@@ -521,6 +522,7 @@ Results:
- `pp_foundation_trace_tests` passed.
- `pp_assets_image_format_tests` passed.
- `pp_paint_blend_tests` passed.
- `pp_document_tests` passed.
- `pano_cli_create_document_smoke` passed.
- `pano_cli_inspect_image_rejects_unsupported` passed as an expected failure
test.

View File

@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[string[]]$Presets = @("android-arm64"),
[string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_paint_blend_tests")
[string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_paint_blend_tests", "pp_document_tests")
)
$ErrorActionPreference = "Stop"

View File

@@ -3,7 +3,7 @@ set -u
preset="${1:-android-arm64}"
shift || true
targets="${*:-pp_foundation pp_assets pp_paint pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests}"
targets="${*:-pp_foundation pp_assets pp_paint pp_document pano_cli pp_foundation_binary_stream_tests pp_foundation_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pp_document_tests}"
start="$(date +%s)"
cmake --preset "$preset"

144
src/document/document.cpp Normal file
View File

@@ -0,0 +1,144 @@
#include "document/document.h"
#include <algorithm>
namespace pp::document {
namespace {
[[nodiscard]] pp::foundation::Status validate_config(DocumentConfig config) noexcept
{
if (config.width == 0 || config.height == 0) {
return pp::foundation::Status::invalid_argument("document dimensions must be greater than zero");
}
if (config.width > max_canvas_dimension || config.height > max_canvas_dimension) {
return pp::foundation::Status::out_of_range("document dimensions exceed the configured limit");
}
if (config.layer_count == 0) {
return pp::foundation::Status::invalid_argument("document must contain at least one layer");
}
if (config.layer_count > max_layer_count) {
return pp::foundation::Status::out_of_range("document layer count exceeds the configured limit");
}
return pp::foundation::Status::success();
}
[[nodiscard]] std::string default_layer_name(std::size_t index)
{
return "Layer " + std::to_string(index + 1U);
}
}
pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig config)
{
const auto status = validate_config(config);
if (!status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(status);
}
CanvasDocument document;
document.width_ = config.width;
document.height_ = config.height;
document.layers_.reserve(config.layer_count);
for (std::uint32_t i = 0; i < config.layer_count; ++i) {
document.layers_.push_back(Layer { .name = default_layer_name(i) });
}
return pp::foundation::Result<CanvasDocument>::success(document);
}
std::uint32_t CanvasDocument::width() const noexcept
{
return width_;
}
std::uint32_t CanvasDocument::height() const noexcept
{
return height_;
}
std::size_t CanvasDocument::active_layer_index() const noexcept
{
return active_layer_index_;
}
std::span<const Layer> CanvasDocument::layers() const noexcept
{
return layers_;
}
pp::foundation::Result<std::size_t> CanvasDocument::add_layer(std::string_view name)
{
if (layers_.size() >= max_layer_count) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("document layer count exceeds the configured limit"));
}
Layer layer;
layer.name = name.empty() ? default_layer_name(layers_.size()) : std::string(name);
layers_.push_back(layer);
active_layer_index_ = layers_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_layer_index_);
}
pp::foundation::Status CanvasDocument::remove_layer(std::size_t index)
{
if (index >= layers_.size()) {
return pp::foundation::Status::out_of_range("layer index is outside the document");
}
if (layers_.size() == 1U) {
return pp::foundation::Status::invalid_argument("document must keep at least one layer");
}
layers_.erase(layers_.begin() + static_cast<std::ptrdiff_t>(index));
if (active_layer_index_ >= layers_.size()) {
active_layer_index_ = layers_.size() - 1U;
} else if (active_layer_index_ > index) {
--active_layer_index_;
}
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::move_layer(std::size_t from, std::size_t to)
{
if (from >= layers_.size() || to >= layers_.size()) {
return pp::foundation::Status::out_of_range("layer index is outside the document");
}
if (from == to) {
return pp::foundation::Status::success();
}
auto layer = layers_[from];
layers_.erase(layers_.begin() + static_cast<std::ptrdiff_t>(from));
layers_.insert(layers_.begin() + static_cast<std::ptrdiff_t>(to), layer);
if (active_layer_index_ == from) {
active_layer_index_ = to;
} else if (from < active_layer_index_ && active_layer_index_ <= to) {
--active_layer_index_;
} else if (to <= active_layer_index_ && active_layer_index_ < from) {
++active_layer_index_;
}
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_active_layer(std::size_t index) noexcept
{
if (index >= layers_.size()) {
return pp::foundation::Status::out_of_range("layer index is outside the document");
}
active_layer_index_ = index;
return pp::foundation::Status::success();
}
}

51
src/document/document.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include "foundation/result.h"
#include "paint/blend.h"
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace pp::document {
constexpr std::uint32_t max_canvas_dimension = 131072;
constexpr std::uint32_t max_layer_count = 1024;
struct DocumentConfig {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint32_t layer_count = 1;
};
struct Layer {
std::string name;
bool visible = true;
float opacity = 1.0F;
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal;
};
class CanvasDocument {
public:
[[nodiscard]] static pp::foundation::Result<CanvasDocument> create(DocumentConfig config);
[[nodiscard]] std::uint32_t width() const noexcept;
[[nodiscard]] std::uint32_t height() const noexcept;
[[nodiscard]] std::size_t active_layer_index() const noexcept;
[[nodiscard]] std::span<const Layer> layers() const noexcept;
[[nodiscard]] pp::foundation::Result<std::size_t> add_layer(std::string_view name);
[[nodiscard]] pp::foundation::Status remove_layer(std::size_t index);
[[nodiscard]] pp::foundation::Status move_layer(std::size_t from, std::size_t to);
[[nodiscard]] pp::foundation::Status set_active_layer(std::size_t index) noexcept;
private:
std::uint32_t width_ = 0;
std::uint32_t height_ = 0;
std::size_t active_layer_index_ = 0;
std::vector<Layer> layers_;
};
}

View File

@@ -56,6 +56,16 @@ add_test(NAME pp_paint_blend_tests COMMAND pp_paint_blend_tests)
set_tests_properties(pp_paint_blend_tests PROPERTIES
LABELS "paint;desktop-fast")
add_executable(pp_document_tests
document/document_tests.cpp)
target_link_libraries(pp_document_tests PRIVATE
pp_document
pp_test_harness)
add_test(NAME pp_document_tests COMMAND pp_document_tests)
set_tests_properties(pp_document_tests PROPERTIES
LABELS "document;desktop-fast")
if(TARGET pano_cli)
add_test(NAME pano_cli_create_document_smoke
COMMAND pano_cli create-document --width 64 --height 32 --layers 2)

View File

@@ -0,0 +1,100 @@
#include "document/document.h"
#include "test_harness.h"
#include <string_view>
using pp::document::CanvasDocument;
using pp::document::DocumentConfig;
using pp::document::max_canvas_dimension;
using pp::document::max_layer_count;
using pp::foundation::StatusCode;
namespace {
void creates_document_with_default_layers(pp::tests::Harness& h)
{
const auto document = CanvasDocument::create(
DocumentConfig { .width = 128, .height = 64, .layer_count = 2 });
PP_EXPECT(h, document.ok());
PP_EXPECT(h, document.value().width() == 128U);
PP_EXPECT(h, document.value().height() == 64U);
PP_EXPECT(h, document.value().layers().size() == 2U);
PP_EXPECT(h, document.value().layers()[0].name == std::string_view("Layer 1"));
PP_EXPECT(h, document.value().layers()[1].name == std::string_view("Layer 2"));
PP_EXPECT(h, document.value().active_layer_index() == 0U);
}
void rejects_invalid_document_configs(pp::tests::Harness& h)
{
const auto zero_width = CanvasDocument::create(
DocumentConfig { .width = 0, .height = 64, .layer_count = 1 });
const auto huge_width = CanvasDocument::create(
DocumentConfig { .width = max_canvas_dimension + 1U, .height = 64, .layer_count = 1 });
const auto no_layers = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 64, .layer_count = 0 });
const auto too_many_layers = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 64, .layer_count = max_layer_count + 1U });
PP_EXPECT(h, !zero_width.ok());
PP_EXPECT(h, zero_width.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !huge_width.ok());
PP_EXPECT(h, huge_width.status().code == StatusCode::out_of_range);
PP_EXPECT(h, !no_layers.ok());
PP_EXPECT(h, no_layers.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !too_many_layers.ok());
PP_EXPECT(h, too_many_layers.status().code == StatusCode::out_of_range);
}
void manages_layer_add_remove_and_active_index(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 64, .layer_count = 1 });
PP_EXPECT(h, document_result.ok());
auto document = document_result.value();
const auto added = document.add_layer("Paint");
PP_EXPECT(h, added.ok());
PP_EXPECT(h, added.value() == 1U);
PP_EXPECT(h, document.active_layer_index() == 1U);
PP_EXPECT(h, document.layers()[1].name == std::string_view("Paint"));
PP_EXPECT(h, document.remove_layer(0).ok());
PP_EXPECT(h, document.layers().size() == 1U);
PP_EXPECT(h, document.active_layer_index() == 0U);
const auto remove_last = document.remove_layer(0);
PP_EXPECT(h, !remove_last.ok());
PP_EXPECT(h, remove_last.code == StatusCode::invalid_argument);
}
void moves_layers_and_preserves_active_layer_identity(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 64, .layer_count = 3 });
PP_EXPECT(h, document_result.ok());
auto document = document_result.value();
PP_EXPECT(h, document.set_active_layer(2).ok());
PP_EXPECT(h, document.move_layer(2, 0).ok());
PP_EXPECT(h, document.active_layer_index() == 0U);
PP_EXPECT(h, document.layers()[0].name == std::string_view("Layer 3"));
PP_EXPECT(h, document.layers()[1].name == std::string_view("Layer 1"));
PP_EXPECT(h, document.layers()[2].name == std::string_view("Layer 2"));
const auto bad_move = document.move_layer(4, 0);
PP_EXPECT(h, !bad_move.ok());
PP_EXPECT(h, bad_move.code == StatusCode::out_of_range);
}
}
int main()
{
pp::tests::Harness harness;
harness.run("creates_document_with_default_layers", creates_document_with_default_layers);
harness.run("rejects_invalid_document_configs", rejects_invalid_document_configs);
harness.run("manages_layer_add_remove_and_active_index", manages_layer_add_remove_and_active_index);
harness.run("moves_layers_and_preserves_active_layer_identity", moves_layers_and_preserves_active_layer_identity);
return harness.finish();
}

View File

@@ -4,4 +4,5 @@ target_link_libraries(pano_cli PRIVATE
pp_project_options
pp_project_warnings
pp_foundation
pp_assets)
pp_assets
pp_document)

View File

@@ -1,4 +1,5 @@
#include "assets/image_format.h"
#include "document/document.h"
#include "foundation/parse.h"
#include "foundation/result.h"
@@ -83,10 +84,22 @@ int create_document(int argc, char** argv)
return 2;
}
const auto document = pp::document::CanvasDocument::create(
pp::document::DocumentConfig {
.width = args.width,
.height = args.height,
.layer_count = args.layers,
});
if (!document) {
print_error("create-document", document.status().message);
return 2;
}
std::cout << "{\"ok\":true,\"command\":\"create-document\",\"document\":{"
<< "\"width\":" << args.width
<< ",\"height\":" << args.height
<< ",\"layers\":" << args.layers
<< "\"width\":" << document.value().width()
<< ",\"height\":" << document.value().height()
<< ",\"layers\":" << document.value().layers().size()
<< ",\"activeLayer\":" << document.value().active_layer_index()
<< "}}\n";
return 0;
}