Add document animation frame tests

This commit is contained in:
2026-06-01 00:16:34 +02:00
parent 4d715afd60
commit dfdb7a4468
6 changed files with 171 additions and 3 deletions

View File

@@ -28,7 +28,7 @@ agent or engineer to remove them without reconstructing context from chat.
| 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 |
| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document model but is not yet wired to legacy `Canvas`, PPI load/save, selection masks, 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 |
| DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Package-smoke covers Windows AppX, Android APK variants, Apple bundles, and WebGL output where local toolchains are present |
## Closed Debt

View File

@@ -306,8 +306,8 @@ 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. `pp_document` has started with a pure canvas/layer model and
layer invariant tests. `pp_renderer_api` has started with renderer-neutral
blend modes. `pp_document` has started with a pure canvas/layer/frame model
and layer/frame invariant tests. `pp_renderer_api` has started with renderer-neutral
texture/readback descriptors and validation tests. `pp_paint_renderer` has
started with deterministic CPU layer compositing over renderer extents using
the paint blend reference. `pp_ui_core` has started with XML-layout-facing

View File

@@ -48,6 +48,7 @@ pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig con
for (std::uint32_t i = 0; i < config.layer_count; ++i) {
document.layers_.push_back(Layer { .name = default_layer_name(i) });
}
document.frames_.push_back(AnimationFrame {});
return pp::foundation::Result<CanvasDocument>::success(document);
}
@@ -67,11 +68,21 @@ std::size_t CanvasDocument::active_layer_index() const noexcept
return active_layer_index_;
}
std::size_t CanvasDocument::active_frame_index() const noexcept
{
return active_frame_index_;
}
std::span<const Layer> CanvasDocument::layers() const noexcept
{
return layers_;
}
std::span<const AnimationFrame> CanvasDocument::frames() const noexcept
{
return frames_;
}
pp::foundation::Result<std::size_t> CanvasDocument::add_layer(std::string_view name)
{
if (layers_.size() >= max_layer_count) {
@@ -141,4 +152,83 @@ pp::foundation::Status CanvasDocument::set_active_layer(std::size_t index) noexc
return pp::foundation::Status::success();
}
pp::foundation::Result<std::size_t> CanvasDocument::add_frame(std::uint32_t duration_ms)
{
if (frames_.size() >= max_frame_count) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("document frame count exceeds the configured limit"));
}
if (duration_ms < min_frame_duration_ms) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::invalid_argument("frame duration must be greater than zero"));
}
frames_.push_back(AnimationFrame { .duration_ms = duration_ms });
active_frame_index_ = frames_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_frame_index_);
}
pp::foundation::Result<std::size_t> CanvasDocument::duplicate_frame(std::size_t index)
{
if (index >= frames_.size()) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("frame index is outside the document"));
}
if (frames_.size() >= max_frame_count) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("document frame count exceeds the configured limit"));
}
const auto insert_at = index + 1U;
frames_.insert(frames_.begin() + static_cast<std::ptrdiff_t>(insert_at), frames_[index]);
active_frame_index_ = insert_at;
return pp::foundation::Result<std::size_t>::success(active_frame_index_);
}
pp::foundation::Status CanvasDocument::remove_frame(std::size_t index)
{
if (index >= frames_.size()) {
return pp::foundation::Status::out_of_range("frame index is outside the document");
}
if (frames_.size() == 1U) {
return pp::foundation::Status::invalid_argument("document must keep at least one frame");
}
frames_.erase(frames_.begin() + static_cast<std::ptrdiff_t>(index));
if (active_frame_index_ >= frames_.size()) {
active_frame_index_ = frames_.size() - 1U;
} else if (active_frame_index_ > index) {
--active_frame_index_;
}
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_frame_duration(std::size_t index, std::uint32_t duration_ms) noexcept
{
if (index >= frames_.size()) {
return pp::foundation::Status::out_of_range("frame index is outside the document");
}
if (duration_ms < min_frame_duration_ms) {
return pp::foundation::Status::invalid_argument("frame duration must be greater than zero");
}
frames_[index].duration_ms = duration_ms;
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_active_frame(std::size_t index) noexcept
{
if (index >= frames_.size()) {
return pp::foundation::Status::out_of_range("frame index is outside the document");
}
active_frame_index_ = index;
return pp::foundation::Status::success();
}
}

View File

@@ -13,6 +13,8 @@ namespace pp::document {
constexpr std::uint32_t max_canvas_dimension = 131072;
constexpr std::uint32_t max_layer_count = 1024;
constexpr std::uint32_t max_frame_count = 100000;
constexpr std::uint32_t min_frame_duration_ms = 1;
struct DocumentConfig {
std::uint32_t width = 0;
@@ -27,6 +29,10 @@ struct Layer {
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal;
};
struct AnimationFrame {
std::uint32_t duration_ms = 100;
};
class CanvasDocument {
public:
[[nodiscard]] static pp::foundation::Result<CanvasDocument> create(DocumentConfig config);
@@ -34,18 +40,28 @@ public:
[[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::size_t active_frame_index() const noexcept;
[[nodiscard]] std::span<const Layer> layers() const noexcept;
[[nodiscard]] std::span<const AnimationFrame> frames() 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;
[[nodiscard]] pp::foundation::Result<std::size_t> add_frame(std::uint32_t duration_ms);
[[nodiscard]] pp::foundation::Result<std::size_t> duplicate_frame(std::size_t index);
[[nodiscard]] pp::foundation::Status remove_frame(std::size_t index);
[[nodiscard]] pp::foundation::Status set_frame_duration(std::size_t index, std::uint32_t duration_ms) noexcept;
[[nodiscard]] pp::foundation::Status set_active_frame(std::size_t index) noexcept;
private:
std::uint32_t width_ = 0;
std::uint32_t height_ = 0;
std::size_t active_layer_index_ = 0;
std::size_t active_frame_index_ = 0;
std::vector<Layer> layers_;
std::vector<AnimationFrame> frames_;
};
}

View File

@@ -6,6 +6,7 @@
using pp::document::CanvasDocument;
using pp::document::DocumentConfig;
using pp::document::max_canvas_dimension;
using pp::document::max_frame_count;
using pp::document::max_layer_count;
using pp::foundation::StatusCode;
@@ -23,6 +24,9 @@ void creates_document_with_default_layers(pp::tests::Harness& h)
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);
PP_EXPECT(h, document.value().frames().size() == 1U);
PP_EXPECT(h, document.value().frames()[0].duration_ms == 100U);
PP_EXPECT(h, document.value().active_frame_index() == 0U);
}
void rejects_invalid_document_configs(pp::tests::Harness& h)
@@ -87,6 +91,60 @@ void moves_layers_and_preserves_active_layer_identity(pp::tests::Harness& h)
PP_EXPECT(h, bad_move.code == StatusCode::out_of_range);
}
void manages_animation_frames_and_duration(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_frame(250);
PP_EXPECT(h, added.ok());
PP_EXPECT(h, added.value() == 1U);
PP_EXPECT(h, document.active_frame_index() == 1U);
PP_EXPECT(h, document.frames()[1].duration_ms == 250U);
const auto duplicated = document.duplicate_frame(1);
PP_EXPECT(h, duplicated.ok());
PP_EXPECT(h, duplicated.value() == 2U);
PP_EXPECT(h, document.frames()[2].duration_ms == 250U);
PP_EXPECT(h, document.set_frame_duration(2, 333).ok());
PP_EXPECT(h, document.frames()[2].duration_ms == 333U);
PP_EXPECT(h, document.remove_frame(1).ok());
PP_EXPECT(h, document.frames().size() == 2U);
PP_EXPECT(h, document.active_frame_index() == 1U);
}
void rejects_invalid_animation_frame_operations(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 zero_duration = document.add_frame(0);
const auto duplicate_missing = document.duplicate_frame(9);
const auto remove_missing = document.remove_frame(9);
const auto remove_only = document.remove_frame(0);
const auto set_bad_duration = document.set_frame_duration(0, 0);
const auto set_missing_active = document.set_active_frame(2);
PP_EXPECT(h, !zero_duration.ok());
PP_EXPECT(h, zero_duration.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !duplicate_missing.ok());
PP_EXPECT(h, duplicate_missing.status().code == StatusCode::out_of_range);
PP_EXPECT(h, !remove_missing.ok());
PP_EXPECT(h, remove_missing.code == StatusCode::out_of_range);
PP_EXPECT(h, !remove_only.ok());
PP_EXPECT(h, remove_only.code == StatusCode::invalid_argument);
PP_EXPECT(h, !set_bad_duration.ok());
PP_EXPECT(h, set_bad_duration.code == StatusCode::invalid_argument);
PP_EXPECT(h, !set_missing_active.ok());
PP_EXPECT(h, set_missing_active.code == StatusCode::out_of_range);
PP_EXPECT(h, max_frame_count > document.frames().size());
}
}
int main()
@@ -96,5 +154,7 @@ int main()
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);
harness.run("manages_animation_frames_and_duration", manages_animation_frames_and_duration);
harness.run("rejects_invalid_animation_frame_operations", rejects_invalid_animation_frame_operations);
return harness.finish();
}

View File

@@ -100,6 +100,8 @@ int create_document(int argc, char** argv)
<< ",\"height\":" << document.value().height()
<< ",\"layers\":" << document.value().layers().size()
<< ",\"activeLayer\":" << document.value().active_layer_index()
<< ",\"frames\":" << document.value().frames().size()
<< ",\"activeFrame\":" << document.value().active_frame_index()
<< "}}\n";
return 0;
}