// Small self-contained checks for the pieces that have no GUI dependency. #include #include #include #include #include #include #include #include #include #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((x + shade) & 0xFF); row[x * 3 + 1] = static_cast((y + shade) & 0xFF); row[x * 3 + 2] = static_cast(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& 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& 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 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 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 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 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"); // Blur declares that it needs no sources, so with none it softens the // empty canvas it starts from instead of refusing to run. const auto blank = renderBlur({}, 100, 100, 1, {}); bool allBlack = blank.width() == 100 && blank.height() == 100; for (std::size_t i = 0; i < blank.byteSize(); ++i) allBlack = allBlack && blank.data()[i] == 0; check(allBlack, "without sources blur renders an empty canvas"); } std::cout << "chain\n"; { std::vector 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"); // Sources are an effect's own business: scanlines cuts strips out of // them, blur only softens what it is handed and runs without any. check(effectByName("scanlines")->needsSources(), "scanlines needs source images"); check(!effectByName("blur")->needsSources(), "blur needs no source images"); check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources"); check(!chainNeedsSources({blur, scanlines}), "a chain led by blur needs none, later stages see the stage before them"); check(!chainNeedsSources({}), "an empty chain needs no sources"); Rng bare(5); const auto sourceless = renderChain({blur}, {}, 200, 150, bare); check(sourceless.width() == 200 && sourceless.height() == 150, "blur renders a canvas without sources"); bool scanlinesThrew = false; try { Rng rng(1); renderChain({scanlines}, {}, 100, 100, rng); } catch (const EffectError&) { scanlinesThrew = true; } check(scanlinesThrew, "scanlines still refuses to run without sources"); } 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(stream)), std::istreambuf_iterator()); 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 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 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 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 lock(mutex); ++calls; lastTotal = total; check(done >= 1 && done <= total, "progress step is within the batch"); }; std::vector 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 cancel{true}; LoadProgress cancelled; cancelled.cancel = &cancel; std::vector 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; }