Files
effector/tests/core_tests.cpp
T
SiandClaude Opus 5 a82fba7886 Save and open projects as JSON
A project is the whole setup as a small text file: the source paths, the
effect chain with its params, the canvas and the seed. Reading and writing
are driven by the effects' ParamSpecs rather than by whatever sits in a
stage's values, so a new effect is saveable without touching this code, a
key an effect does not declare is never written or read back, and an old
project keeps the defaults for params that did not exist when it was
saved. Paths under the project's own folder are written relative, so a
folder of images and their project can be moved whole.

src/core/project.cpp carries its own small JSON reader instead of a
dependency, and names the line when a hand-edited file is wrong.

The interface gets a header-bar menu: New, Open, Save, Save As, with
Ctrl+N/O/S. The title shows the project name. Opening a project replaces
the sources, discarding a decode still in flight rather than mixing it
into the new project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:37:37 +02:00

388 lines
16 KiB
C++

// Small self-contained checks for the pieces that have no GUI dependency.
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>
#include <vector>
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "effects/blur.hpp"
#include "effects/scanlines.hpp"
namespace {
int failures = 0;
void check(bool condition, const std::string& what) {
if (condition) {
std::cout << " ok " << what << "\n";
} else {
std::cout << " FAIL " << what << "\n";
++failures;
}
}
effector::Image gradient(int width, int height, int shade) {
effector::Image image(width, height);
for (int y = 0; y < height; ++y) {
auto* row = image.row(y);
for (int x = 0; x < width; ++x) {
row[x * 3 + 0] = static_cast<std::uint8_t>((x + shade) & 0xFF);
row[x * 3 + 1] = static_cast<std::uint8_t>((y + shade) & 0xFF);
row[x * 3 + 2] = static_cast<std::uint8_t>(shade);
}
}
return image;
}
bool sameBytes(const effector::Image& a, const effector::Image& b) {
if (a.width() != b.width() || a.height() != b.height()) return false;
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
}
// Alternating black and white columns: any blur along x has to pull these
// towards mid grey.
effector::Image stripes(int width, int height) {
effector::Image image(width, height);
for (int y = 0; y < height; ++y) {
auto* row = image.row(y);
for (int x = 0; x < width; ++x) {
const std::uint8_t value = (x % 2 == 0) ? 0 : 255;
row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value;
}
}
return image;
}
effector::Image renderBlur(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::BlurEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
} // namespace
int main() {
using namespace effector;
std::cout << "rng\n";
{
Rng a(42);
Rng b(42);
bool identical = true;
for (int i = 0; i < 1000; ++i) identical = identical && a.next() == b.next();
check(identical, "same seed yields the same stream");
Rng c(7);
bool inBounds = true;
for (int i = 0; i < 10000; ++i) {
const long value = c.inRange(-4, 9);
inBounds = inBounds && value >= -4 && value <= 9;
}
check(inBounds, "inRange stays within bounds");
}
std::cout << "registry\n";
{
check(!effects().empty(), "registry is populated");
check(effectByName("scanlines")->title() == "Scanlines", "lookup by name");
bool threw = false;
try {
effectByName("nope");
} catch (const EffectError&) {
threw = true;
}
check(threw, "unknown effect throws");
}
std::cout << "scanlines\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
gradient(300, 200, 200)};
const auto first = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
const auto again = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
const auto other = render(sources, 800, 600, 2, {{"strip_height", 6L}, {"jitter", 3L}});
check(first.width() == 800 && first.height() == 600, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
check(!sameBytes(first, other), "a different seed changes the image");
const auto thin = render(sources, 400, 300, 7, {{"strip_height", 1L}});
check(thin.width() == 400 && thin.height() == 300, "strip height of 1");
// Canvas wider and taller than the only source: exercises tiling and the
// clamp of the strip height to the source height.
std::vector<Image> small = {gradient(300, 200, 5)};
const auto tiled = render(small, 1000, 700, 9, {{"strip_height", 400L}, {"jitter", 50L}});
check(tiled.width() == 1000 && tiled.height() == 700, "tiling a narrow source");
bool tiles = true;
for (int y = 0; y < tiled.height(); ++y) {
const auto* row = tiled.row(y);
for (int c = 0; c < 3; ++c) tiles = tiles && row[c] == row[300 * 3 + c];
}
check(tiles, "tiled columns repeat with the source width");
const auto ordered = render(sources, 500, 500, 3, {{"strip_height", 20L}, {"keep_order", true}});
check(ordered.height() == 500, "keep_order fills the canvas");
bool threw = false;
try {
render({}, 100, 100, 1, {});
} catch (const EffectError&) {
threw = true;
}
check(threw, "no sources throws");
}
std::cout << "blur\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}});
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
check(!sameBytes(first, other), "a different seed changes the image");
// A wide blur over one-pixel stripes has to land near mid grey, and only
// along the axis it is applied to.
std::vector<Image> striped = {stripes(200, 100)};
const auto flattened = renderBlur(striped, 200, 100, 1,
{{"radius", 8L}, {"direction", std::string("horizontal")}});
// The clamped edges ramp in over radius * passes pixels on each side.
bool grey = true;
for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2;
check(grey, "a horizontal blur evens out vertical stripes");
const auto untouched = renderBlur(striped, 200, 100, 1,
{{"radius", 8L}, {"direction", std::string("vertical")}});
check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255,
"a vertical blur leaves columns alone");
// Every column is uniform, so a vertical blur must not change anything.
const auto vertical = renderBlur(striped, 200, 100, 1,
{{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}});
check(sameBytes(untouched, vertical), "repeated vertical passes stay stable");
// Radius larger than the canvas must clamp rather than read out of bounds.
const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}});
check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped");
const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
check(sameBytes(pinned, pinnedAgain), "first_source is reproducible");
bool threw = false;
try {
renderBlur({}, 100, 100, 1, {});
} catch (const EffectError&) {
threw = true;
}
check(threw, "no sources throws");
}
std::cout << "chain\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
ChainStage blur{effectByName("blur"), {{"radius", 6L}}};
Rng single(5);
const auto direct = renderChain({scanlines}, sources, 400, 300, single);
const auto alone = render(sources, 400, 300, 5, {{"strip_height", 6L}});
check(sameBytes(direct, alone), "a one stage chain matches the effect on its own");
Rng a(5);
Rng b(5);
const auto cascaded = renderChain({scanlines, blur}, sources, 400, 300, a);
const auto again = renderChain({scanlines, blur}, sources, 400, 300, b);
check(cascaded.width() == 400 && cascaded.height() == 300, "the chain keeps the canvas size");
check(sameBytes(cascaded, again), "the same seed reproduces the cascade");
check(!sameBytes(cascaded, direct), "a second stage changes the image");
// Order matters: blurring scanlines is not the same as scanning a blur.
Rng forward(5);
Rng backward(5);
const auto blurLast = renderChain({scanlines, blur}, sources, 400, 300, forward);
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
bool threw = false;
try {
Rng rng(1);
renderChain({}, sources, 100, 100, rng);
} catch (const EffectError&) {
threw = true;
}
check(threw, "an empty chain throws");
}
std::cout << "project\n";
{
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
std::filesystem::create_directories(dir);
const auto source = dir / "source.png";
gradient(64, 48, 7).save(source.string());
Project project;
project.sources = {source.string(), "/elsewhere/other.png"};
project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}});
project.stages.push_back(
{effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}});
project.width = 640;
project.height = 400;
project.seed = 1234567890123ULL;
const auto file = dir / "test.json";
saveProject(file.string(), project);
const auto reloaded = loadProject(file.string());
check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip");
check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip");
check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" &&
reloaded.stages[1].effect->name() == "blur",
"the chain keeps its effects and their order");
check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 &&
paramInt(reloaded.stages[1].values, "radius", 0) == 3 &&
paramChoice(reloaded.stages[1].values, "direction", "") == "vertical",
"the stage params survive a round trip");
check(paramInt(reloaded.stages[0].values, "jitter", -1) ==
paramInt(effectByName("scanlines")->defaults(), "jitter", -2),
"params the file leaves out keep their default");
// A source next to the project is written relative, so the folder can
// be moved; anything outside it stays absolute.
std::ifstream stream(file.string(), std::ios::binary);
const std::string text((std::istreambuf_iterator<char>(stream)),
std::istreambuf_iterator<char>());
check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative");
check(text.find("\"/elsewhere/other.png\"") != std::string::npos,
"a source elsewhere stays absolute");
check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(),
"a relative source is resolved against the project");
// The point of a project: the same picture comes back out of the file.
std::vector<Image> sources = {gradient(300, 200, 4)};
Rng before(*project.seed);
Rng after(*reloaded.seed);
const auto original = renderChain(project.stages, sources, project.width, project.height, before);
const auto restored =
renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after);
check(sameBytes(original, restored), "a reloaded project renders the same image");
// Params the effects do not declare are dropped rather than carried.
const auto stripped = projectFromJson(projectToJson(reloaded));
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
"a stage only keeps the params its effect declares");
int refusals = 0;
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {
try {
projectFromJson(bad);
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 4, "a broken project is refused");
bool threw = false;
try {
loadProject((dir / "missing.json").string());
} catch (const EffectError&) {
threw = true;
}
check(threw, "a missing project file throws");
std::filesystem::remove_all(dir);
}
std::cout << "image io\n";
{
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
const auto original = gradient(64, 48, 3);
original.save(path.string());
const auto reloaded = Image::load(path.string());
check(sameBytes(original, reloaded), "png round trip is lossless");
std::filesystem::remove(path);
bool threw = false;
try {
Image::load("/definitely/not/here.png");
} catch (const std::exception&) {
threw = true;
}
check(threw, "missing file throws");
}
std::cout << "batch loading\n";
{
const auto dir = std::filesystem::temp_directory_path() / "effector_test_batch";
std::filesystem::create_directories(dir);
std::vector<std::string> paths;
for (int i = 0; i < 6; ++i) {
const auto path = dir / ("src" + std::to_string(i) + ".png");
gradient(32, 24, i + 1).save(path.string());
paths.push_back(path.string());
}
paths.push_back((dir / "missing.png").string());
std::atomic<std::size_t> calls{0};
std::size_t lastTotal = 0;
std::mutex mutex;
LoadProgress progress;
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
std::lock_guard<std::mutex> lock(mutex);
++calls;
lastTotal = total;
check(done >= 1 && done <= total, "progress step is within the batch");
};
std::vector<LoadFailure> failuresOut;
const auto images = loadImages(paths, failuresOut, &progress);
check(images.size() == 6, "every readable image is returned");
check(failuresOut.size() == 1, "the unreadable one is reported");
check(calls.load() == paths.size(), "progress fires once per file");
check(lastTotal == paths.size(), "progress reports the batch size");
check(images.front().path() == paths.front(), "input order is preserved");
std::atomic<bool> cancel{true};
LoadProgress cancelled;
cancelled.cancel = &cancel;
std::vector<LoadFailure> ignored;
const auto none = loadImages(paths, ignored, &cancelled);
check(none.empty(), "a cancelled batch loads nothing");
std::filesystem::remove_all(dir);
}
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
return failures == 0 ? 0 : 1;
}