summaryrefslogtreecommitdiff
path: root/src/tests
diff options
context:
space:
mode:
Diffstat (limited to 'src/tests')
-rw-r--r--src/tests/effect_test_helpers.cc121
-rw-r--r--src/tests/effect_test_helpers.h54
-rw-r--r--src/tests/offscreen_render_target.cc170
-rw-r--r--src/tests/offscreen_render_target.h53
-rw-r--r--src/tests/test_effect_base.cc253
-rw-r--r--src/tests/webgpu_test_fixture.cc145
-rw-r--r--src/tests/webgpu_test_fixture.h54
7 files changed, 850 insertions, 0 deletions
diff --git a/src/tests/effect_test_helpers.cc b/src/tests/effect_test_helpers.cc
new file mode 100644
index 0000000..a31c447
--- /dev/null
+++ b/src/tests/effect_test_helpers.cc
@@ -0,0 +1,121 @@
+// This file is part of the 64k demo project.
+// It implements reusable test helpers for GPU effect testing.
+// Provides pixel validation and lifecycle testing utilities.
+
+#if !defined(STRIP_ALL) // Test code only - zero size impact on final binary
+
+#include "effect_test_helpers.h"
+#include "gpu/effect.h"
+#include <cassert>
+
+// ============================================================================
+// Pixel Validation Helpers
+// ============================================================================
+
+bool validate_pixels(
+ const std::vector<uint8_t>& pixels,
+ int width,
+ int height,
+ std::function<bool(uint8_t r, uint8_t g, uint8_t b, uint8_t a)> predicate) {
+ const size_t pixel_count = width * height;
+ for (size_t i = 0; i < pixel_count; ++i) {
+ const size_t offset = i * 4; // BGRA8 = 4 bytes/pixel
+ const uint8_t b = pixels[offset + 0];
+ const uint8_t g = pixels[offset + 1];
+ const uint8_t r = pixels[offset + 2];
+ const uint8_t a = pixels[offset + 3];
+
+ if (predicate(r, g, b, a)) {
+ return true; // At least one pixel matches
+ }
+ }
+ return false; // No pixels matched
+}
+
+bool has_rendered_content(const std::vector<uint8_t>& pixels,
+ int width,
+ int height) {
+ return validate_pixels(pixels, width, height,
+ [](uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
+ return r > 0 || g > 0 || b > 0;
+ });
+}
+
+bool all_pixels_match_color(const std::vector<uint8_t>& pixels,
+ int width,
+ int height,
+ uint8_t target_r,
+ uint8_t target_g,
+ uint8_t target_b,
+ uint8_t tolerance) {
+ const size_t pixel_count = width * height;
+ for (size_t i = 0; i < pixel_count; ++i) {
+ const size_t offset = i * 4;
+ const uint8_t b = pixels[offset + 0];
+ const uint8_t g = pixels[offset + 1];
+ const uint8_t r = pixels[offset + 2];
+
+ const int diff_r = static_cast<int>(r) - static_cast<int>(target_r);
+ const int diff_g = static_cast<int>(g) - static_cast<int>(target_g);
+ const int diff_b = static_cast<int>(b) - static_cast<int>(target_b);
+
+ if (diff_r * diff_r + diff_g * diff_g + diff_b * diff_b >
+ tolerance * tolerance) {
+ return false; // At least one pixel doesn't match
+ }
+ }
+ return true; // All pixels match
+}
+
+uint64_t hash_pixels(const std::vector<uint8_t>& pixels) {
+ // Simple FNV-1a hash
+ uint64_t hash = 14695981039346656037ULL;
+ for (const uint8_t byte : pixels) {
+ hash ^= byte;
+ hash *= 1099511628211ULL;
+ }
+ return hash;
+}
+
+// ============================================================================
+// Effect Lifecycle Helpers
+// ============================================================================
+
+bool test_effect_lifecycle(Effect* effect, MainSequence* main_seq) {
+ assert(effect && "Effect pointer is null");
+ assert(main_seq && "MainSequence pointer is null");
+
+ // Check initial state
+ if (effect->is_initialized) {
+ return false; // Should not be initialized yet
+ }
+
+ // Initialize effect
+ effect->init(main_seq);
+
+ // Check initialized state
+ if (!effect->is_initialized) {
+ return false; // Should be initialized now
+ }
+
+ return true; // Lifecycle test passed
+}
+
+bool test_effect_render_smoke(Effect* effect) {
+ assert(effect && "Effect pointer is null");
+
+ // Smoke test: Just call render with dummy parameters
+ // If this doesn't crash, consider it a success
+ // Note: This requires the effect to be initialized first
+ if (!effect->is_initialized) {
+ return false; // Cannot render uninitialized effect
+ }
+
+ // We cannot actually render without a full render pass setup
+ // This is a placeholder for more sophisticated render testing
+ // Real render tests should use OffscreenRenderTarget
+
+ return true; // Smoke test passed (no crash)
+}
+
+#endif /* !defined(STRIP_ALL) */
diff --git a/src/tests/effect_test_helpers.h b/src/tests/effect_test_helpers.h
new file mode 100644
index 0000000..d48daa7
--- /dev/null
+++ b/src/tests/effect_test_helpers.h
@@ -0,0 +1,54 @@
+// This file is part of the 64k demo project.
+// It provides reusable test helpers for GPU effect testing.
+// Includes lifecycle helpers, pixel validation, and smoke tests.
+
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <vector>
+
+// Forward declarations
+class Effect;
+class MainSequence;
+
+// ============================================================================
+// Pixel Validation Helpers
+// ============================================================================
+
+// Validate pixels using a predicate function
+// Returns true if at least one pixel matches the predicate
+bool validate_pixels(
+ const std::vector<uint8_t>& pixels,
+ int width,
+ int height,
+ std::function<bool(uint8_t r, uint8_t g, uint8_t b, uint8_t a)> predicate);
+
+// Check if any pixel is non-black (rendered something)
+bool has_rendered_content(const std::vector<uint8_t>& pixels,
+ int width,
+ int height);
+
+// Check if all pixels match a specific color (within tolerance)
+bool all_pixels_match_color(const std::vector<uint8_t>& pixels,
+ int width,
+ int height,
+ uint8_t r,
+ uint8_t g,
+ uint8_t b,
+ uint8_t tolerance = 5);
+
+// Compute simple hash of pixel data (for deterministic output checks)
+uint64_t hash_pixels(const std::vector<uint8_t>& pixels);
+
+// ============================================================================
+// Effect Lifecycle Helpers
+// ============================================================================
+
+// Test that an effect can be constructed and initialized
+// Returns true if lifecycle succeeds, false otherwise
+bool test_effect_lifecycle(Effect* effect, MainSequence* main_seq);
+
+// Test that an effect can render without crashing (smoke test)
+// Does not validate output, only checks for crashes
+bool test_effect_render_smoke(Effect* effect);
diff --git a/src/tests/offscreen_render_target.cc b/src/tests/offscreen_render_target.cc
new file mode 100644
index 0000000..81ad082
--- /dev/null
+++ b/src/tests/offscreen_render_target.cc
@@ -0,0 +1,170 @@
+// This file is part of the 64k demo project.
+// It implements offscreen rendering for headless GPU testing.
+// Provides pixel readback for validation.
+
+#if !defined(STRIP_ALL) // Test code only - zero size impact on final binary
+
+#include "offscreen_render_target.h"
+#include <cassert>
+#include <cstdio>
+#include <cstring>
+
+OffscreenRenderTarget::OffscreenRenderTarget(WGPUInstance instance,
+ WGPUDevice device,
+ int width,
+ int height,
+ WGPUTextureFormat format)
+ : instance_(instance),
+ device_(device),
+ width_(width),
+ height_(height),
+ format_(format) {
+ // Create offscreen texture
+ const WGPUTextureDescriptor texture_desc = {
+ .usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc,
+ .dimension = WGPUTextureDimension_2D,
+ .size = {static_cast<uint32_t>(width), static_cast<uint32_t>(height), 1},
+ .format = format,
+ .mipLevelCount = 1,
+ .sampleCount = 1,
+ };
+ texture_ = wgpuDeviceCreateTexture(device_, &texture_desc);
+ assert(texture_ && "Failed to create offscreen texture");
+
+ // Create texture view
+ const WGPUTextureViewDescriptor view_desc = {
+ .format = format,
+ .dimension = WGPUTextureViewDimension_2D,
+ .baseMipLevel = 0,
+ .mipLevelCount = 1,
+ .baseArrayLayer = 0,
+ .arrayLayerCount = 1,
+ };
+ view_ = wgpuTextureCreateView(texture_, &view_desc);
+ assert(view_ && "Failed to create offscreen texture view");
+}
+
+OffscreenRenderTarget::~OffscreenRenderTarget() {
+ if (view_) {
+ wgpuTextureViewRelease(view_);
+ }
+ if (texture_) {
+ wgpuTextureRelease(texture_);
+ }
+}
+
+void OffscreenRenderTarget::map_callback(WGPUMapAsyncStatus status,
+ void* userdata) {
+ MapState* state = static_cast<MapState*>(userdata);
+ state->status = status;
+ state->done = true;
+}
+
+WGPUBuffer OffscreenRenderTarget::create_staging_buffer() {
+ const size_t buffer_size = width_ * height_ * 4; // BGRA8 = 4 bytes/pixel
+ const WGPUBufferDescriptor buffer_desc = {
+ .usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead,
+ .size = buffer_size,
+ };
+ return wgpuDeviceCreateBuffer(device_, &buffer_desc);
+}
+
+std::vector<uint8_t> OffscreenRenderTarget::read_pixels() {
+ const size_t buffer_size = width_ * height_ * 4; // BGRA8
+ std::vector<uint8_t> pixels(buffer_size);
+
+ // Create staging buffer for readback
+ WGPUBuffer staging = create_staging_buffer();
+ assert(staging && "Failed to create staging buffer");
+
+ // Create command encoder for copy operation
+ const WGPUCommandEncoderDescriptor enc_desc = {};
+ WGPUCommandEncoder encoder =
+ wgpuDeviceCreateCommandEncoder(device_, &enc_desc);
+
+ // Copy texture to buffer
+ const WGPUTexelCopyTextureInfo src = {
+ .texture = texture_,
+ .mipLevel = 0,
+ .origin = {0, 0, 0},
+ };
+
+ const WGPUTexelCopyBufferInfo dst = {
+ .buffer = staging,
+ .layout =
+ {
+ .bytesPerRow = static_cast<uint32_t>(width_ * 4),
+ .rowsPerImage = static_cast<uint32_t>(height_),
+ },
+ };
+
+ const WGPUExtent3D copy_size = {static_cast<uint32_t>(width_),
+ static_cast<uint32_t>(height_), 1};
+
+ wgpuCommandEncoderCopyTextureToBuffer(encoder, &src, &dst, &copy_size);
+
+ // Submit commands
+ WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, nullptr);
+ wgpuQueueSubmit(wgpuDeviceGetQueue(device_), 1, &commands);
+ wgpuCommandBufferRelease(commands);
+ wgpuCommandEncoderRelease(encoder);
+
+ // Map buffer for reading (API differs between Win32 and native)
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ // Win32: Old callback API
+ MapState map_state = {};
+ auto map_cb = [](WGPUBufferMapAsyncStatus status, void* userdata) {
+ MapState* state = static_cast<MapState*>(userdata);
+ state->status = status;
+ state->done = true;
+ };
+ wgpuBufferMapAsync(staging, WGPUMapMode_Read, 0, buffer_size, map_cb,
+ &map_state);
+#else
+ // Native: New callback info API
+ MapState map_state = {};
+ auto map_cb = [](WGPUMapAsyncStatus status, WGPUStringView message,
+ void* userdata, void* user2) {
+ (void)message;
+ (void)user2;
+ MapState* state = static_cast<MapState*>(userdata);
+ state->status = status;
+ state->done = true;
+ };
+ WGPUBufferMapCallbackInfo map_info = {};
+ map_info.mode = WGPUCallbackMode_WaitAnyOnly;
+ map_info.callback = map_cb;
+ map_info.userdata1 = &map_state;
+ wgpuBufferMapAsync(staging, WGPUMapMode_Read, 0, buffer_size, map_info);
+#endif
+
+ // Wait for mapping to complete
+ for (int i = 0; i < 100 && !map_state.done; ++i) {
+#if defined(__EMSCRIPTEN__)
+ emscripten_sleep(10);
+#else
+ wgpuInstanceProcessEvents(instance_);
+#endif
+ }
+
+ if (map_state.status != WGPUMapAsyncStatus_Success) {
+ fprintf(stderr, "Buffer mapping failed: %d\n", map_state.status);
+ wgpuBufferRelease(staging);
+ return pixels; // Return empty
+ }
+
+ // Copy data from mapped buffer
+ const uint8_t* mapped_data = static_cast<const uint8_t*>(
+ wgpuBufferGetConstMappedRange(staging, 0, buffer_size));
+ if (mapped_data) {
+ memcpy(pixels.data(), mapped_data, buffer_size);
+ }
+
+ // Cleanup
+ wgpuBufferUnmap(staging);
+ wgpuBufferRelease(staging);
+
+ return pixels;
+}
+
+#endif /* !defined(STRIP_ALL) */
diff --git a/src/tests/offscreen_render_target.h b/src/tests/offscreen_render_target.h
new file mode 100644
index 0000000..4163ec1
--- /dev/null
+++ b/src/tests/offscreen_render_target.h
@@ -0,0 +1,53 @@
+// This file is part of the 64k demo project.
+// It provides offscreen rendering without windows (headless testing).
+// Enables pixel readback for frame validation in tests.
+
+#pragma once
+
+#include "platform/platform.h"
+#include <cstdint>
+#include <vector>
+
+// Offscreen render target for headless GPU testing
+// Creates a texture that can be rendered to and read back
+class OffscreenRenderTarget {
+ public:
+ // Create an offscreen render target with specified dimensions
+ OffscreenRenderTarget(WGPUInstance instance,
+ WGPUDevice device,
+ int width,
+ int height,
+ WGPUTextureFormat format = WGPUTextureFormat_BGRA8Unorm);
+ ~OffscreenRenderTarget();
+
+ // Accessors
+ WGPUTexture texture() const { return texture_; }
+ WGPUTextureView view() const { return view_; }
+ int width() const { return width_; }
+ int height() const { return height_; }
+ WGPUTextureFormat format() const { return format_; }
+
+ // Read pixels from the render target
+ // Returns BGRA8 pixel data (width * height * 4 bytes)
+ std::vector<uint8_t> read_pixels();
+
+ private:
+ WGPUInstance instance_;
+ WGPUDevice device_;
+ WGPUTexture texture_;
+ WGPUTextureView view_;
+ int width_;
+ int height_;
+ WGPUTextureFormat format_;
+
+ // Helper: Create staging buffer for readback
+ WGPUBuffer create_staging_buffer();
+
+ // Callback state for async buffer mapping
+ struct MapState {
+ bool done = false;
+ WGPUMapAsyncStatus status = WGPUMapAsyncStatus_Unknown;
+ };
+
+ static void map_callback(WGPUMapAsyncStatus status, void* userdata);
+};
diff --git a/src/tests/test_effect_base.cc b/src/tests/test_effect_base.cc
new file mode 100644
index 0000000..bc837be
--- /dev/null
+++ b/src/tests/test_effect_base.cc
@@ -0,0 +1,253 @@
+// This file is part of the 64k demo project.
+// It tests the Effect/Sequence/MainSequence lifecycle using headless rendering.
+// Verifies effect initialization, activation, and basic rendering.
+
+#include "effect_test_helpers.h"
+#include "offscreen_render_target.h"
+#include "webgpu_test_fixture.h"
+#include "gpu/demo_effects.h"
+#include "gpu/effect.h"
+#include <cassert>
+#include <cstdio>
+#include <memory>
+
+// Test 1: WebGPU fixture initialization
+static void test_webgpu_fixture() {
+ fprintf(stdout, "Testing WebGPU fixture...\n");
+
+ WebGPUTestFixture fixture;
+ const bool init_success = fixture.init();
+
+ if (!init_success) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ assert(fixture.is_initialized() && "Fixture should be initialized");
+ assert(fixture.device() != nullptr && "Device should be valid");
+ assert(fixture.queue() != nullptr && "Queue should be valid");
+
+ fprintf(stdout, " ✓ WebGPU fixture initialized successfully\n");
+
+ fixture.shutdown();
+ assert(!fixture.is_initialized() && "Fixture should be shutdown");
+
+ fprintf(stdout, " ✓ WebGPU fixture shutdown successfully\n");
+}
+
+// Test 2: Offscreen render target creation
+static void test_offscreen_render_target() {
+ fprintf(stdout, "Testing offscreen render target...\n");
+
+ WebGPUTestFixture fixture;
+ if (!fixture.init()) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ OffscreenRenderTarget target(fixture.instance(), fixture.device(), 256, 256);
+
+ assert(target.texture() != nullptr && "Texture should be valid");
+ assert(target.view() != nullptr && "Texture view should be valid");
+ assert(target.width() == 256 && "Width should be 256");
+ assert(target.height() == 256 && "Height should be 256");
+
+ fprintf(stdout, " ✓ Offscreen render target created (256x256)\n");
+
+ // Test pixel readback (should initially be all zeros or uninitialized)
+ const std::vector<uint8_t> pixels = target.read_pixels();
+ assert(pixels.size() == 256 * 256 * 4 && "Pixel buffer size should match");
+
+ fprintf(stdout, " ✓ Pixel readback succeeded (%zu bytes)\n", pixels.size());
+}
+
+// Test 3: Effect construction
+static void test_effect_construction() {
+ fprintf(stdout, "Testing effect construction...\n");
+
+ WebGPUTestFixture fixture;
+ if (!fixture.init()) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ // Create FlashEffect (simple post-process effect)
+ auto effect = std::make_shared<FlashEffect>(
+ fixture.device(), fixture.queue(), fixture.format());
+
+ assert(!effect->is_initialized && "Effect should not be initialized yet");
+
+ fprintf(stdout, " ✓ FlashEffect constructed (not initialized)\n");
+}
+
+// Test 4: Effect initialization via Sequence
+static void test_effect_initialization() {
+ fprintf(stdout, "Testing effect initialization...\n");
+
+ WebGPUTestFixture fixture;
+ if (!fixture.init()) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ // Create MainSequence (use init_test for test environment)
+ MainSequence main_seq;
+ main_seq.init_test(fixture.device(), fixture.queue(), fixture.format());
+
+ // Create FlashEffect
+ auto effect = std::make_shared<FlashEffect>(
+ fixture.device(), fixture.queue(), fixture.format());
+
+ assert(!effect->is_initialized && "Effect should not be initialized yet");
+
+ // Add effect to sequence
+ auto seq = std::make_shared<Sequence>();
+ seq->add_effect(effect, 0.0f, 10.0f, 0);
+
+ // Initialize sequence (this sets effect->is_initialized)
+ seq->init(&main_seq);
+
+ assert(effect->is_initialized && "Effect should be initialized after Sequence::init()");
+
+ fprintf(stdout, " ✓ FlashEffect initialized via Sequence::init()\n");
+}
+
+// Test 5: Sequence add_effect
+static void test_sequence_add_effect() {
+ fprintf(stdout, "Testing Sequence::add_effect...\n");
+
+ WebGPUTestFixture fixture;
+ if (!fixture.init()) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ MainSequence main_seq;
+ main_seq.init_test(fixture.device(), fixture.queue(), fixture.format());
+
+ // Create sequence
+ auto seq = std::make_shared<Sequence>();
+
+ // Create effect
+ auto effect = std::make_shared<FlashEffect>(
+ fixture.device(), fixture.queue(), fixture.format());
+
+ assert(!effect->is_initialized && "Effect should not be initialized before Sequence::init()");
+
+ // Add effect to sequence (time range: 0.0 - 10.0, priority 0)
+ seq->add_effect(effect, 0.0f, 10.0f, 0);
+
+ // Initialize sequence (this should initialize the effect)
+ seq->init(&main_seq);
+
+ assert(effect->is_initialized && "Effect should be initialized after Sequence::init()");
+
+ fprintf(stdout, " ✓ Effect added to sequence and initialized (time=0.0-10.0, priority=0)\n");
+}
+
+// Test 6: Sequence activation logic
+static void test_sequence_activation() {
+ fprintf(stdout, "Testing sequence activation logic...\n");
+
+ WebGPUTestFixture fixture;
+ if (!fixture.init()) {
+ fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n");
+ return;
+ }
+
+ MainSequence main_seq;
+ main_seq.init_test(fixture.device(), fixture.queue(), fixture.format());
+
+ auto seq = std::make_shared<Sequence>();
+ auto effect = std::make_shared<FlashEffect>(
+ fixture.device(), fixture.queue(), fixture.format());
+
+ // Effect active from 5.0 to 10.0 seconds
+ seq->add_effect(effect, 5.0f, 10.0f, 0);
+ seq->init(&main_seq);
+
+ // Before start time: should not be active
+ seq->update_active_list(-1.0f);
+ std::vector<SequenceItem*> scene_before, post_before;
+ seq->collect_active_effects(scene_before, post_before);
+ assert(scene_before.empty() && post_before.empty() &&
+ "Effect should not be active before start time");
+
+ fprintf(stdout, " ✓ Effect not active before start time (t=-1.0)\n");
+
+ // At start time: should be active
+ seq->update_active_list(5.0f);
+ std::vector<SequenceItem*> scene_at_start, post_at_start;
+ seq->collect_active_effects(scene_at_start, post_at_start);
+ const size_t active_at_start = scene_at_start.size() + post_at_start.size();
+ assert(active_at_start == 1 && "Effect should be active at start time");
+
+ fprintf(stdout, " ✓ Effect active at start time (t=5.0)\n");
+
+ // During active period: should remain active
+ seq->update_active_list(7.5f);
+ std::vector<SequenceItem*> scene_during, post_during;
+ seq->collect_active_effects(scene_during, post_during);
+ const size_t active_during = scene_during.size() + post_during.size();
+ assert(active_during == 1 && "Effect should be active during period");
+
+ fprintf(stdout, " ✓ Effect active during period (t=7.5)\n");
+
+ // After end time: should not be active
+ seq->update_active_list(11.0f);
+ std::vector<SequenceItem*> scene_after, post_after;
+ seq->collect_active_effects(scene_after, post_after);
+ assert(scene_after.empty() && post_after.empty() &&
+ "Effect should not be active after end time");
+
+ fprintf(stdout, " ✓ Effect not active after end time (t=11.0)\n");
+}
+
+// Test 7: Pixel validation helpers
+static void test_pixel_helpers() {
+ fprintf(stdout, "Testing pixel validation helpers...\n");
+
+ // Test has_rendered_content (should detect non-black pixels)
+ std::vector<uint8_t> black_frame(256 * 256 * 4, 0);
+ assert(!has_rendered_content(black_frame, 256, 256) &&
+ "Black frame should have no content");
+
+ std::vector<uint8_t> colored_frame(256 * 256 * 4, 0);
+ colored_frame[0] = 255; // Set one red pixel
+ assert(has_rendered_content(colored_frame, 256, 256) &&
+ "Colored frame should have content");
+
+ fprintf(stdout, " ✓ has_rendered_content() works correctly\n");
+
+ // Test all_pixels_match_color
+ std::vector<uint8_t> red_frame(256 * 256 * 4, 0);
+ for (size_t i = 0; i < 256 * 256; ++i) {
+ red_frame[i * 4 + 2] = 255; // BGRA: Red in position 2
+ }
+ assert(all_pixels_match_color(red_frame, 256, 256, 255, 0, 0, 5) &&
+ "Red frame should match red color");
+
+ fprintf(stdout, " ✓ all_pixels_match_color() works correctly\n");
+
+ // Test hash_pixels
+ const uint64_t hash1 = hash_pixels(black_frame);
+ const uint64_t hash2 = hash_pixels(colored_frame);
+ assert(hash1 != hash2 && "Different frames should have different hashes");
+
+ fprintf(stdout, " ✓ hash_pixels() produces unique hashes\n");
+}
+
+int main() {
+ fprintf(stdout, "=== Effect Base Tests ===\n");
+
+ test_webgpu_fixture();
+ test_offscreen_render_target();
+ test_effect_construction();
+ test_effect_initialization();
+ test_sequence_add_effect();
+ test_sequence_activation();
+ test_pixel_helpers();
+
+ fprintf(stdout, "=== All Effect Base Tests Passed ===\n");
+ return 0;
+}
diff --git a/src/tests/webgpu_test_fixture.cc b/src/tests/webgpu_test_fixture.cc
new file mode 100644
index 0000000..750dea0
--- /dev/null
+++ b/src/tests/webgpu_test_fixture.cc
@@ -0,0 +1,145 @@
+// This file is part of the 64k demo project.
+// It implements shared WebGPU initialization for GPU tests.
+// Provides graceful fallback if GPU unavailable.
+
+#if !defined(STRIP_ALL) // Test code only - zero size impact on final binary
+
+#include "webgpu_test_fixture.h"
+#include <cstdio>
+#include <cstdlib>
+
+WebGPUTestFixture::WebGPUTestFixture() {
+}
+
+WebGPUTestFixture::~WebGPUTestFixture() {
+ shutdown();
+}
+
+bool WebGPUTestFixture::init() {
+ // Create instance
+ const WGPUInstanceDescriptor instance_desc = {};
+ instance_ = wgpuCreateInstance(&instance_desc);
+ if (!instance_) {
+ fprintf(stderr,
+ "WebGPU not available (wgpuCreateInstance failed) - skipping GPU "
+ "test\n");
+ return false;
+ }
+
+ // Request adapter (API differs between Win32 and native)
+ WGPUAdapter adapter = nullptr;
+ const WGPURequestAdapterOptions adapter_opts = {
+ .compatibleSurface = nullptr,
+ .powerPreference = WGPUPowerPreference_HighPerformance,
+ };
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ // Win32: Old callback API (function pointer + userdata)
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter a,
+ const char* message, void* userdata) {
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = a;
+ } else if (message) {
+ fprintf(stderr, "Adapter request failed: %s\n", message);
+ }
+ };
+ wgpuInstanceRequestAdapter(instance_, &adapter_opts, on_adapter, &adapter);
+#else
+ // Native: New callback info API
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter a,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ (void)message;
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = a;
+ }
+ };
+ WGPURequestAdapterCallbackInfo adapter_cb = {};
+ adapter_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ adapter_cb.callback = on_adapter;
+ adapter_cb.userdata1 = &adapter;
+ wgpuInstanceRequestAdapter(instance_, &adapter_opts, adapter_cb);
+#endif
+
+ // Wait for adapter callback
+ for (int i = 0; i < 100 && !adapter; ++i) {
+ wgpuInstanceProcessEvents(instance_);
+ }
+
+ if (!adapter) {
+ fprintf(stderr, "No WebGPU adapter available - skipping GPU test\n");
+ shutdown();
+ return false;
+ }
+
+ adapter_ = adapter;
+
+ // Request device (API differs between Win32 and native)
+ WGPUDevice device = nullptr;
+ const WGPUDeviceDescriptor device_desc = {};
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ // Win32: Old callback API
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice d,
+ const char* message, void* userdata) {
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = d;
+ } else if (message) {
+ fprintf(stderr, "Device request failed: %s\n", message);
+ }
+ };
+ wgpuAdapterRequestDevice(adapter_, &device_desc, on_device, &device);
+#else
+ // Native: New callback info API
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice d,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ (void)message;
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = d;
+ }
+ };
+ WGPURequestDeviceCallbackInfo device_cb = {};
+ device_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ device_cb.callback = on_device;
+ device_cb.userdata1 = &device;
+ wgpuAdapterRequestDevice(adapter_, &device_desc, device_cb);
+#endif
+
+ // Wait for device callback
+ for (int i = 0; i < 100 && !device; ++i) {
+ wgpuInstanceProcessEvents(instance_);
+ }
+
+ if (!device) {
+ fprintf(stderr, "Failed to create WebGPU device - skipping GPU test\n");
+ shutdown();
+ return false;
+ }
+
+ device_ = device;
+ queue_ = wgpuDeviceGetQueue(device_);
+
+ return true;
+}
+
+void WebGPUTestFixture::shutdown() {
+ if (queue_) {
+ wgpuQueueRelease(queue_);
+ queue_ = nullptr;
+ }
+ if (device_) {
+ wgpuDeviceRelease(device_);
+ device_ = nullptr;
+ }
+ if (adapter_) {
+ wgpuAdapterRelease(adapter_);
+ adapter_ = nullptr;
+ }
+ if (instance_) {
+ wgpuInstanceRelease(instance_);
+ instance_ = nullptr;
+ }
+}
+
+#endif /* !defined(STRIP_ALL) */
diff --git a/src/tests/webgpu_test_fixture.h b/src/tests/webgpu_test_fixture.h
new file mode 100644
index 0000000..2c700a4
--- /dev/null
+++ b/src/tests/webgpu_test_fixture.h
@@ -0,0 +1,54 @@
+// This file is part of the 64k demo project.
+// It provides shared WebGPU initialization for GPU tests.
+// Eliminates boilerplate and enables graceful skipping if GPU unavailable.
+
+#pragma once
+
+#include "platform/platform.h"
+
+// Shared test fixture for WebGPU tests
+// Handles device/queue initialization and cleanup
+class WebGPUTestFixture {
+ public:
+ WebGPUTestFixture();
+ ~WebGPUTestFixture();
+
+ // Initialize WebGPU device and queue
+ // Returns true on success, false if GPU unavailable (test should skip)
+ bool init();
+
+ // Cleanup resources
+ void shutdown();
+
+ // Accessors
+ WGPUInstance instance() const { return instance_; }
+ WGPUDevice device() const { return device_; }
+ WGPUQueue queue() const { return queue_; }
+ WGPUTextureFormat format() const { return WGPUTextureFormat_BGRA8Unorm; }
+
+ // Check if fixture is ready
+ bool is_initialized() const { return device_ != nullptr; }
+
+ private:
+ WGPUInstance instance_ = nullptr;
+ WGPUAdapter adapter_ = nullptr;
+ WGPUDevice device_ = nullptr;
+ WGPUQueue queue_ = nullptr;
+
+ // Callback state for async device request
+ struct RequestState {
+ WGPUAdapter adapter = nullptr;
+ WGPUDevice device = nullptr;
+ bool done = false;
+ };
+
+ static void adapter_callback(WGPURequestAdapterStatus status,
+ WGPUAdapter adapter,
+ const char* message,
+ void* userdata);
+
+ static void device_callback(WGPURequestDeviceStatus status,
+ WGPUDevice device,
+ const char* message,
+ void* userdata);
+};