// This file is part of the 64k demo project. // It tests the Effect/Sequence lifecycle using headless rendering. // Verifies effect initialization and basic rendering. #include "../common/effect_test_helpers.h" #include "../common/offscreen_render_target.h" #include "../common/webgpu_test_fixture.h" #include "effects/passthrough_effect.h" #include "gpu/effect.h" #include "gpu/sequence.h" #include #include #include // 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 pixels = target.read_pixels(); // Note: Buffer mapping may fail on some systems (WebGPU driver issue) // Don't fail the test if readback returns empty buffer if (pixels.empty()) { fprintf(stdout, " ⚠ Pixel readback skipped (buffer mapping unavailable)\n"); } else { 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 Passthrough (simple effect) auto effect = std::make_shared( fixture.ctx(), std::vector{"source"}, std::vector{"sink"}, 0.0f, 1000.0f); assert(effect != nullptr && "Effect should be constructed"); fprintf(stdout, " ✓ Passthrough constructed\n"); } // Test 4: Effect added to sequence DAG static void test_effect_in_sequence() { fprintf(stdout, "Testing effect in Sequence DAG...\n"); WebGPUTestFixture fixture; if (!fixture.init()) { fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); return; } // Create minimal sequence with one effect class TestSequence : public Sequence { public: TestSequence(const GpuContext& ctx, int w, int h) : Sequence(ctx, w, h) { auto effect = std::make_shared( ctx, std::vector{"source"}, std::vector{"sink"}, 0.0f, 1000.0f); effect_dag_.push_back({effect, {"source"}, {"sink"}, 0}); init_effect_nodes(); } }; auto seq = std::make_unique(fixture.ctx(), 256, 256); assert(seq->get_effect_dag().size() == 1 && "Should have one effect"); assert(seq->get_effect_dag()[0].effect != nullptr && "Effect should exist"); fprintf(stdout, " ✓ Effect added to DAG and initialized\n"); } // Test 5: Sequence rendering (smoke test) static void test_sequence_render() { fprintf(stdout, "Testing sequence render...\n"); WebGPUTestFixture fixture; if (!fixture.init()) { fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); return; } OffscreenRenderTarget target(fixture.instance(), fixture.device(), 256, 256); class TestSequence : public Sequence { public: TestSequence(const GpuContext& ctx, int w, int h) : Sequence(ctx, w, h) { auto effect = std::make_shared( ctx, std::vector{"source"}, std::vector{"sink"}, 0.0f, 1000.0f); effect_dag_.push_back({effect, {"source"}, {"sink"}, 0}); init_effect_nodes(); } }; auto seq = std::make_unique(fixture.ctx(), 256, 256); seq->set_sink_view(target.view()); // Note: source uses default texture from NodeRegistry, not target.view() // (can't read and write same texture in one pass) // Preprocess before rendering seq->preprocess(0.0f, 0.0f, 0.0f, 0.0f); // Create encoder and attempt render WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(fixture.device(), nullptr); seq->render_effects(encoder); WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, nullptr); wgpuQueueSubmit(fixture.queue(), 1, &commands); wgpuCommandBufferRelease(commands); wgpuCommandEncoderRelease(encoder); // Read back pixels to ensure the GPU finishes rendering before teardown. // This avoids intermittent crashes on shutdown. target.read_pixels(); fprintf(stdout, " ✓ Sequence rendered without error\n"); } // Test 6: Sequence time-based parameters static void test_sequence_time_params() { fprintf(stdout, "Testing sequence time parameters...\n"); WebGPUTestFixture fixture; if (!fixture.init()) { fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); return; } class TestSequence : public Sequence { public: TestSequence(const GpuContext& ctx, int w, int h) : Sequence(ctx, w, h) { init_effect_nodes(); } void preprocess(float seq_time, float beat_time, float beat_phase, float audio_intensity) override { Sequence::preprocess(seq_time, beat_time, beat_phase, audio_intensity); last_time = seq_time; } float last_time = -1.0f; }; auto seq = std::make_unique(fixture.ctx(), 256, 256); // Test different time values seq->preprocess(0.0f, 0.0f, 0.0f, 0.0f); assert(seq->last_time == 0.0f && "Time at t=0"); seq->preprocess(5.5f, 10.0f, 0.5f, 0.8f); assert(seq->last_time == 5.5f && "Time at t=5.5"); fprintf(stdout, " ✓ Sequence time parameters updated correctly\n"); } // Minimal Effect subclass for wire_dag / find_downstream_output tests. // Exposes the protected helper and records what wire_dag received. class WireDagTestEffect : public Effect { public: WireDagTestEffect(const GpuContext& ctx, std::vector ins, std::vector outs) : Effect(ctx, std::move(ins), std::move(outs), 0.0f, 1000.0f) {} void render(WGPUCommandEncoder, const UniformsSequenceParams&, NodeRegistry&) override {} std::string call_find_downstream(const std::vector& dag) const { return find_downstream_output(dag); } std::string wired_to; void wire_dag(const std::vector& dag) override { wired_to = find_downstream_output(dag); } }; // Test 7: find_downstream_output DAG query static void test_find_downstream_output() { fprintf(stdout, "Testing find_downstream_output...\n"); WebGPUTestFixture fixture; if (!fixture.init()) { fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); return; } auto a = std::make_shared( fixture.ctx(), std::vector{"src"}, std::vector{"mid"}); auto b = std::make_shared( fixture.ctx(), std::vector{"mid"}, std::vector{"out"}); auto c = std::make_shared( fixture.ctx(), std::vector{"out"}, std::vector{"final"}); // Two-node chain: A→B. A's downstream is B, returns B's output "out". std::vector dag_ab = { {a, {"src"}, {"mid"}, 0}, {b, {"mid"}, {"out"}, 1}, }; assert(a->call_find_downstream(dag_ab) == "out" && "A's downstream output should be 'out'"); fprintf(stdout, " ✓ two-node chain: correct downstream output\n"); // Three-node chain: A→B→C. A finds B first (not C). std::vector dag_abc = { {a, {"src"}, {"mid"}, 0}, {b, {"mid"}, {"out"}, 1}, {c, {"out"}, {"final"}, 2}, }; assert(a->call_find_downstream(dag_abc) == "out" && "A should find first downstream, not transitive"); fprintf(stdout, " ✓ three-node chain: first downstream only\n"); // No downstream: A is the last node. std::vector dag_a_only = { {a, {"src"}, {"mid"}, 0}, }; assert(a->call_find_downstream(dag_a_only) == "" && "No downstream should return empty string"); fprintf(stdout, " ✓ no downstream: returns empty string\n"); // Unrelated node: B does not consume A's output. auto unrelated = std::make_shared( fixture.ctx(), std::vector{"other"}, std::vector{"sink"}); std::vector dag_unrelated = { {a, {"src"}, {"mid"}, 0}, {unrelated, {"other"}, {"sink"}, 1}, }; assert(a->call_find_downstream(dag_unrelated) == "" && "Unrelated node should not match"); fprintf(stdout, " ✓ unrelated node: returns empty string\n"); // Downstream outputs to "sink" (external view, no owned texture). // wire_dag must not wire to it — GBufferEffect skips "sink" outputs. auto to_sink = std::make_shared( fixture.ctx(), std::vector{"mid"}, std::vector{"sink"}); std::vector dag_to_sink = { {a, {"src"}, {"mid"}, 0}, {to_sink, {"mid"}, {"sink"}, 1}, }; // find_downstream_output returns "sink" (it's agnostic) assert(a->call_find_downstream(dag_to_sink) == "sink"); // but wire_dag on a WireDagTestEffect just stores whatever find returns; // verify GBufferEffect-style guard: "sink" should NOT be wired as prev a->wire_dag(dag_to_sink); assert(a->wired_to == "sink" && "base helper returns sink — caller must guard"); fprintf(stdout, " ✓ sink downstream: find returns 'sink', caller must guard\n"); } // Test 8: wire_dag called automatically by init_effect_nodes static void test_wire_dag_called_by_sequence() { fprintf(stdout, "Testing wire_dag called by init_effect_nodes...\n"); WebGPUTestFixture fixture; if (!fixture.init()) { fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); return; } auto upstream = std::make_shared( fixture.ctx(), std::vector{"source"}, std::vector{"mid"}); auto downstream = std::make_shared( fixture.ctx(), std::vector{"mid"}, std::vector{"sink"}); class TestSequence : public Sequence { public: TestSequence(const GpuContext& ctx, std::shared_ptr up, std::shared_ptr down) : Sequence(ctx, 256, 256) { effect_dag_.push_back({up, {"source"}, {"mid"}, 0}); effect_dag_.push_back({down, {"mid"}, {"sink"}, 1}); init_effect_nodes(); // triggers wire_dag on both effects } }; TestSequence seq(fixture.ctx(), upstream, downstream); assert(upstream->wired_to == "sink" && "upstream should be wired to downstream's output 'sink'"); assert(downstream->wired_to == "" && "downstream has no consumer, should be empty"); fprintf(stdout, " ✓ upstream wired_to='sink', downstream wired_to=''\n"); } // Test 9: 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 black_frame(256 * 256 * 4, 0); assert(!has_rendered_content(black_frame, 256, 256) && "Black frame should have no content"); std::vector 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 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"); extern void InitShaderComposer(); InitShaderComposer(); test_webgpu_fixture(); test_offscreen_render_target(); test_effect_construction(); test_effect_in_sequence(); test_sequence_render(); test_sequence_time_params(); test_find_downstream_output(); test_wire_dag_called_by_sequence(); test_pixel_helpers(); fprintf(stdout, "=== All Effect Base Tests Passed ===\n"); return 0; }