Files
effector/tests/core_tests.cpp
T
SiandClaude Opus 5 eb417bb41a Let a chain stage be switched off without taking it apart
Trying a stage out meant removing it and building it again from scratch,
settings and all, to see what the picture looked like without it. A stage now
carries an `enabled` flag and can simply be switched off: it keeps its place
in the chain and everything it was set to.

`enabledStages` is the one place that reads the flag, and renderChain,
chainNeedsSources, chainTitle and chainSlug all go through it. A disabled
stage is skipped whole — handed no image and drawing nothing from the shared
rng — so switching one off renders byte for byte what the chain rendered
before that stage was added, which is the only thing that makes the switch
worth anything while comparing. Whichever stage runs first is the one handed
the sources, so disabling a leading scanlines lets blur lead and the chain
needs no images at all. The status line and the generated file name follow
too: they name what was rendered, not what sits in the sidebar. A chain with
every stage disabled throws rather than quietly rendering nothing.

In the interface it is a check on the chain row: the row dims, the params
heading says "(off)" and the settings stay editable — a stage being worked on
is not the same as one being deleted. On the command line it is
`--disable NAME`, which picks its stage by effect name exactly as the params
do. A project writes `"enabled": false` only for a stage that is off, so
existing project files are unchanged and an ordinary one stays uncluttered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:11:17 +02:00

1320 lines
62 KiB
C++

// Small self-contained checks for the pieces that have no GUI dependency.
#include <array>
#include <atomic>
#include <clocale>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <locale>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "cli/options.hpp"
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp"
#include "effects/text.hpp"
#include "effects/tiles.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;
}
// A canvas taken from a project file, written by hand rather than by
// projectToJson, so a size no writer would produce can be fed to the reader.
std::string projectWithCanvas(const std::string& width, const std::string& height) {
return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height +
"},\n \"chain\": [{\"effect\": \"blur\"}]}";
}
// The canvas a `-s` value ends up as, or nothing when the parser refuses it.
std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
char program[] = "effector";
char flag[] = "-s";
std::vector<char> value(size.begin(), size.end());
value.push_back('\0');
char* argv[] = {program, flag, value.data()};
try {
const auto options = effector::parseOptions(3, argv);
return std::pair<int, int>{options.width, options.height};
} catch (const effector::EffectError&) {
return std::nullopt;
}
}
// The options a whole command line ends up as, or nothing when the parser
// refuses it.
std::optional<effector::Options> parsedOptions(const std::vector<std::string>& args) {
std::vector<std::vector<char>> storage;
storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'});
for (const auto& arg : args) {
std::vector<char> buffer(arg.begin(), arg.end());
buffer.push_back('\0');
storage.push_back(std::move(buffer));
}
std::vector<char*> argv;
for (auto& buffer : storage) argv.push_back(buffer.data());
try {
return effector::parseOptions(static_cast<int>(argv.size()), argv.data());
} catch (const effector::EffectError&) {
return std::nullopt;
}
}
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 renderGrain(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::GrainEffect 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 renderMono(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::MonochromeEffect 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 renderOverlay(const std::vector<effector::Image>& sources, int width, int height,
const effector::ParamValues& values) {
effector::OverlayEffect effect;
effector::Rng rng(7);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
effector::Image renderText(const std::vector<effector::Image>& sources, int width, int height,
const effector::ParamValues& values) {
effector::TextEffect effect;
effector::Rng rng(7);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
effector::Image renderTiles(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::TilesEffect 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);
}
// The three channels of one pixel, to read a placement off directly.
std::array<int, 3> pixel(const effector::Image& image, int x, int y) {
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
return {px[0], px[1], px[2]};
}
bool isRed(const std::array<int, 3>& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; }
bool isBlue(const std::array<int, 3>& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; }
// A flat field of one colour, to read the channel weighting off directly.
effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
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] = r;
row[x * 3 + 1] = g;
row[x * 3 + 2] = b;
}
}
return image;
}
// A flat field of one shade, to measure what the grain does to it.
effector::Image flat(int width, int height, std::uint8_t shade) {
effector::Image image(width, height);
std::fill(image.data(), image.data() + image.byteSize(), shade);
return image;
}
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 << "tiles\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
gradient(300, 200, 200)};
for (const std::string layout : {"grid", "brick", "scatter"}) {
const ParamValues values = {{"tile_layout", layout},
{"tile_width", 70L},
{"tile_height", 50L},
{"tile_jitter", 12L}};
const auto first = renderTiles(sources, 400, 300, 1, values);
const auto again = renderTiles(sources, 400, 300, 1, values);
const auto other = renderTiles(sources, 400, 300, 2, values);
check(first.width() == 400 && first.height() == 300, layout + " has the requested size");
check(sameBytes(first, again), layout + ": same seed reproduces the image");
check(!sameBytes(first, other), layout + ": a different seed changes the image");
}
// With no jitter the grid cells line up: the gradient's red channel
// counts up with x inside a tile and only breaks at a cell boundary.
const auto aligned = renderTiles(sources, 400, 300, 5,
{{"tile_layout", std::string("grid")}, {"tile_width", 100L},
{"tile_height", 100L}});
bool onlyAtBoundaries = true;
for (int y = 0; y < aligned.height(); ++y) {
const auto* row = aligned.row(y);
for (int x = 1; x < aligned.width(); ++x) {
const bool breaks = row[x * 3] != ((row[(x - 1) * 3] + 1) & 0xFF);
if (breaks && x % 100 != 0) onlyAtBoundaries = false;
}
}
check(onlyAtBoundaries, "grid cells break only on the cell boundaries");
// One source smaller than a single tile: the cut is repeated to fill
// the cell, so the canvas is covered rather than left black.
std::vector<Image> small = {gradient(60, 40, 200)};
const auto covered = renderTiles(small, 500, 400, 9,
{{"tile_layout", std::string("brick")},
{"tile_width", 200L},
{"tile_height", 150L}});
bool filled = true;
for (int y = 0; y < covered.height(); ++y) {
const auto* row = covered.row(y);
for (int x = 0; x < covered.width(); ++x) filled = filled && row[x * 3 + 2] == 200;
}
check(filled, "a source smaller than a tile still covers the canvas");
const auto tiny = renderTiles(sources, 120, 90, 3,
{{"tile_width", 1L}, {"tile_height", 1L}});
check(tiny.width() == 120 && tiny.height() == 90, "tiles one pixel across");
const auto ordered = renderTiles(sources, 300, 200, 4,
{{"tile_width", 40L}, {"tile_keep_order", true}});
check(ordered.height() == 200, "keep_order fills the canvas");
// A single scattered tile leaves the rest of the canvas to the backdrop.
const auto sparse = renderTiles(small, 400, 300, 6,
{{"tile_layout", std::string("scatter")}, {"tile_count", 1L}});
bool backdrop = true;
for (int y = 0; y < sparse.height(); ++y) {
const auto* row = sparse.row(y);
for (int x = 0; x < sparse.width(); ++x) backdrop = backdrop && row[x * 3 + 2] == 200;
}
check(backdrop, "scattered tiles land on a filled backdrop");
bool threw = false;
try {
renderTiles({}, 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");
// 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 << "grain\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderGrain(sources, 400, 300, 4, {});
const auto again = renderGrain(sources, 400, 300, 4, {});
const auto other = renderGrain(sources, 400, 300, 11, {});
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");
// Mid grey is where the grain is strongest, so it must move, and stay
// grey while the channels are drawn together.
std::vector<Image> grey = {flat(200, 100, 128)};
const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}});
bool moved = false;
bool neutral = true;
for (int x = 0; x < 200; ++x) {
const auto* px = grained.row(50) + x * 3;
moved = moved || px[0] != 128;
neutral = neutral && px[0] == px[1] && px[1] == px[2];
}
check(moved, "grain moves a flat mid grey");
check(neutral, "monochrome grain keeps the channels equal");
const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}});
bool split = false;
for (int x = 0; x < 200; ++x) {
const auto* px = coloured.row(50) + x * 3;
split = split || px[0] != px[1] || px[1] != px[2];
}
check(split, "coloured grain pulls the channels apart");
// With the midtone weighting on, black and white are left alone; with
// it off the grain lands everywhere.
const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {});
bool untouched = true;
for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) {
untouched = untouched && blackWeighted.data()[i] == 0;
}
check(untouched, "the midtone weighting leaves black alone");
const auto blackFlat =
renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}});
bool lifted = false;
for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) {
lifted = lifted || blackFlat.data()[i] != 0;
}
check(lifted, "without the weighting grain reaches the shadows");
// Zero amount is a pass through, and a grain wider than the canvas must
// still only read the one cell it has.
check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}),
flat(32, 32, 128)),
"zero amount leaves the image untouched");
const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}});
check(coarse.width() == 40 && coarse.height() == 30,
"a grain wider than the canvas is clamped");
// Like blur, grain needs no sources: with none it grains the empty
// canvas it starts from instead of refusing to run.
const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}});
bool speckled = blank.width() == 100 && blank.height() == 100;
bool any = false;
for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0;
check(speckled && any, "without sources grain works on an empty canvas");
}
std::cout << "monochrome\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderMono(sources, 400, 300, 4, {});
const auto again = renderMono(sources, 400, 300, 4, {});
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
bool grey = true;
for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) {
grey = grey && first.data()[i] == first.data()[i + 1] &&
first.data()[i + 1] == first.data()[i + 2];
}
check(grey, "every pixel comes out neutral grey");
// A pure red field: the luminance weighting keeps little of it, the red
// weighting nearly all, and the blue weighting almost none.
std::vector<Image> red = {solid(64, 64, 255, 0, 0)};
const auto viaLuma = renderMono(red, 64, 64, 1, {});
const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}});
const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}});
check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red");
check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light");
check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark");
// Contrast works about mid grey: it leaves that alone and pushes the
// shades either side of it apart.
const auto midHard =
renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}});
check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey");
const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {});
const auto darkHard =
renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}});
check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows");
// The toners tint the greys, each in its own direction.
const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
{{"mono_tone", std::string("sepia")}});
check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2],
"sepia warms the greys");
const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
{{"mono_tone", std::string("cyanotype")}});
check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0],
"the cyanotype toner cools them");
// Strength mixes back towards the colour input: zero is a pass through,
// half sits between the two.
check(sameBytes(
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}),
solid(32, 32, 200, 40, 10)),
"zero strength leaves the colours untouched");
const auto half =
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}});
const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {});
check(half.data()[0] > full.data()[0] && half.data()[0] < 200,
"half strength sits between the grey and the colour");
// Like blur and grain it needs no sources: with none it converts the
// empty canvas, which stays black.
const auto blank = renderMono({}, 50, 50, 1, {});
bool allBlack = blank.width() == 50 && blank.height() == 50;
for (std::size_t i = 0; i < blank.byteSize(); ++i) {
allBlack = allBlack && blank.data()[i] == 0;
}
check(allBlack, "without sources monochrome renders an empty canvas");
}
std::cout << "overlay\n";
{
const auto dir = std::filesystem::temp_directory_path() / "effector_test_overlay";
std::filesystem::create_directories(dir);
const auto logo = (dir / "logo.png").string();
solid(40, 20, 255, 0, 0).save(logo); // wider than it is tall, to tell the fittings apart
const std::vector<Image> blue = {solid(100, 100, 0, 0, 255)};
const ParamValues at = {{"overlay_image", logo}, {"overlay_fit", std::string("original")}};
const auto centred = renderOverlay(blue, 100, 100, at);
check(isRed(pixel(centred, 50, 50)), "the overlay lands in the middle of the canvas");
check(isBlue(pixel(centred, 2, 2)), "and leaves the rest of the input alone");
check(isRed(pixel(centred, 30, 40)) && isBlue(pixel(centred, 29, 40)),
"centred, a 40x20 image starts 30 pixels in");
ParamValues corner = at;
corner["overlay_center"] = false;
const auto topLeft = renderOverlay(blue, 100, 100, corner);
check(isRed(pixel(topLeft, 0, 0)) && isBlue(pixel(topLeft, 41, 0)),
"without centring it sits in the top left corner");
ParamValues nudged = corner;
nudged["overlay_offset_x"] = 10L;
nudged["overlay_offset_y"] = 5L;
const auto moved = renderOverlay(blue, 100, 100, nudged);
check(isRed(pixel(moved, 10, 5)) && isBlue(pixel(moved, 9, 5)) && isBlue(pixel(moved, 10, 4)),
"the offset moves it right and down");
ParamValues back = at;
back["overlay_offset_x"] = -25L;
const auto half = renderOverlay(blue, 100, 100, back);
check(isRed(pixel(half, 5, 50)) && isBlue(pixel(half, 96, 50)),
"a negative offset moves it off the left edge, and it is clipped");
// Fitting: fit shows the whole image and keeps its proportions, so a
// 40x20 logo becomes 100x50 with a band of input above and below; fill
// and stretch and tile all cover the canvas.
ParamValues fitted = at;
fitted["overlay_fit"] = std::string("fit");
const auto fit = renderOverlay(blue, 100, 100, fitted);
check(isBlue(pixel(fit, 50, 10)) && isRed(pixel(fit, 50, 50)) && isRed(pixel(fit, 1, 50)),
"fit shows the whole image, in its own proportions");
for (const char* mode : {"fill", "stretch", "tile"}) {
ParamValues covering = at;
covering["overlay_fit"] = std::string(mode);
const auto image = renderOverlay(blue, 100, 100, covering);
bool everywhere = true;
for (int y = 0; y < 100; y += 7) {
for (int x = 0; x < 100; x += 7) everywhere = everywhere && isRed(pixel(image, x, y));
}
check(everywhere, std::string(mode) + " covers the whole canvas");
}
// Scale resizes what the fitting worked out: half of 100x50 is 50x25.
ParamValues scaled = fitted;
scaled["overlay_scale"] = 0.5;
const auto smaller = renderOverlay(blue, 100, 100, scaled);
check(isRed(pixel(smaller, 50, 50)) && isBlue(pixel(smaller, 20, 50)),
"the scale shrinks the fitted image about its centre");
// Opacity mixes over the input rather than replacing it.
ParamValues faint = at;
faint["overlay_opacity"] = 0.5;
const auto mixed = pixel(renderOverlay(blue, 100, 100, faint), 50, 50);
check(mixed[0] > 100 && mixed[0] < 155 && mixed[2] > 100 && mixed[2] < 155,
"half opacity sits between the overlay and the input");
check(sameBytes(renderOverlay(blue, 100, 100, {{"overlay_image", logo},
{"overlay_opacity", 0.0}}),
solid(100, 100, 0, 0, 255)),
"zero opacity leaves the input untouched");
// No image chosen is the state the interface starts in: a pass through.
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
"with no image chosen the stage passes its input through");
// Like blur and monochrome it needs no sources of its own.
check(!OverlayEffect().needsSources(), "the overlay can lead a chain");
const auto alone = renderOverlay({}, 60, 40, {{"overlay_image", logo},
{"overlay_fit", std::string("stretch")}});
check(alone.width() == 60 && isRed(pixel(alone, 30, 20)),
"without sources it draws on an empty canvas");
bool threw = false;
try {
renderOverlay(blue, 100, 100, {{"overlay_image", std::string("/nope/missing.png")}});
} catch (const EffectError&) {
threw = true;
}
check(threw, "a missing overlay image is reported");
// The path travels with the project, written relative to its folder.
Project project;
project.width = 100;
project.height = 100;
project.stages.push_back({effectByName("overlay"), {{"overlay_image", logo}}});
const auto json = projectToJson(project, dir.string());
check(json.find("\"logo.png\"") != std::string::npos,
"an overlay image inside the project folder is written relative");
const auto reread = projectFromJson(json, dir.string());
check(paramText(reread.stages[0].values, "overlay_image", "") == logo,
"and comes back as the same file");
std::filesystem::remove_all(dir);
}
std::cout << "colour params\n";
{
std::uint8_t rgb[3] = {0, 0, 0};
check(parseColor("#ff8800", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"a six digit colour is read");
check(parseColor("00FF80", rgb) && rgb[1] == 255 && rgb[2] == 128,
"the hash is optional and the case does not matter");
check(parseColor("#f80", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"the three digit shorthand doubles each digit");
check(!parseColor("#12345", rgb) && !parseColor("#gg0000", rgb) && !parseColor("", rgb),
"anything else is refused");
const std::uint8_t written[3] = {1, 171, 255};
check(formatColor(written) == "#01abff", "a colour is written back as six digits");
// A value that does not parse falls back rather than painting
// something arbitrary.
std::uint8_t fallback[3] = {0, 0, 0};
paramColor({{"c", std::string("nonsense")}}, "c", "#102030", fallback);
check(fallback[0] == 0x10 && fallback[1] == 0x20 && fallback[2] == 0x30,
"a malformed colour keeps the default");
// Both new types survive a project round trip, and the reader drops a
// colour it cannot read exactly as it drops an unknown choice.
Project project;
project.width = 64;
project.height = 64;
project.stages.push_back({effectByName("text"),
{{"text", std::string("a \"quoted\" line")},
{"text_color", std::string("#ff0080")}}});
const auto reread = projectFromJson(projectToJson(project, ""), "");
check(paramText(reread.stages[0].values, "text", "") == "a \"quoted\" line",
"text with quotes in it survives a project round trip");
check(paramText(reread.stages[0].values, "text_color", "") == "#ff0080",
"and so does a colour");
const auto broken = projectFromJson(
"{\"chain\": [{\"effect\": \"text\", \"params\": {\"text_color\": \"#zz\"}}]}", "");
check(paramText(broken.stages[0].values, "text_color", "") == "#ffffff",
"a colour a project cannot be read keeps the effect's default");
// The command line refuses one too, rather than drawing black.
bool threw = false;
try {
const char* argv[] = {"effector", "-e", "text", "--text-color", "purple"};
parseOptions(5, const_cast<char**>(argv));
} catch (const EffectError&) {
threw = true;
}
check(threw, "an unparseable colour on the command line is reported");
}
std::cout << "text\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const ParamValues plain = {{"text", std::string("Hi")},
{"font_size", 60.0},
{"text_color", std::string("#ff0000")}};
// Counting the red pixels is how every placement below is read: the
// exact glyphs belong to whatever font this machine has.
const auto countRed = [](const Image& image, int x0, int y0, int x1, int y1) {
int found = 0;
for (int y = y0; y < y1; ++y) {
for (int x = x0; x < x1; ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto centred = renderText(blue, 200, 120, plain);
check(countRed(centred, 60, 30, 140, 90) > 0, "the text lands in the middle of the canvas");
check(countRed(centred, 0, 0, 200, 120) == countRed(centred, 50, 20, 150, 100),
"and nothing is written outside it");
ParamValues corner = plain;
corner["text_center"] = false;
const auto topLeft = renderText(blue, 200, 120, corner);
check(countRed(topLeft, 0, 0, 100, 60) > 0 && countRed(topLeft, 100, 60, 200, 120) == 0,
"uncentred it starts at the top left corner");
ParamValues nudged = corner;
nudged["text_offset_x"] = 100L;
nudged["text_offset_y"] = 60L;
const auto moved = renderText(blue, 200, 120, nudged);
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
"the offset moves it down and to the right");
// Weight and size are the two knobs the glyphs themselves answer to: a
// bolder or a larger setting covers strictly more of the canvas.
ParamValues bold = plain;
bold["font_weight"] = std::string("heavy");
check(countRed(renderText(blue, 200, 120, bold), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"a heavier weight inks more pixels than a normal one");
ParamValues large = plain;
large["font_size"] = 90.0;
check(countRed(renderText(blue, 200, 120, large), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"and so does a larger size");
// The box is drawn under the text, in a colour or from a picture.
ParamValues boxed = plain;
boxed["text_box"] = std::string("colour");
boxed["box_color"] = std::string("#00ff00");
boxed["box_padding"] = 10L;
const auto onBox = renderText(blue, 200, 120, boxed);
const auto corner_px = pixel(onBox, 2, 2);
check(corner_px[2] > 200, "the box leaves the canvas outside it alone");
bool greenSomewhere = false;
for (int y = 0; y < 120 && !greenSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onBox, x, y);
if (px[1] > 200 && px[0] < 60 && px[2] < 60) greenSomewhere = true;
}
}
check(greenSomewhere, "and is filled with the colour it was given");
check(countRed(onBox, 0, 0, 200, 120) > 0, "the text is drawn over the box");
ParamValues faint = boxed;
faint["box_opacity"] = 0.5;
const auto half = renderText(blue, 200, 120, faint);
bool mixed = false;
for (int y = 0; y < 120 && !mixed; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(half, x, y);
// Half the green box over the blue input, and neither of them.
if (px[1] > 100 && px[1] < 200 && px[2] > 100 && px[2] < 200) mixed = true;
}
}
check(mixed, "half a box opacity sits between the box and the input");
const auto dir = std::filesystem::temp_directory_path() / "effector_test_text";
std::filesystem::create_directories(dir);
const auto picture = (dir / "fill.png").string();
solid(40, 40, 255, 255, 0).save(picture);
ParamValues fromPicture = boxed;
fromPicture["text_box"] = std::string("image");
fromPicture["box_image"] = picture;
const auto onPicture = renderText(blue, 200, 120, fromPicture);
bool yellowSomewhere = false;
for (int y = 0; y < 120 && !yellowSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onPicture, x, y);
if (px[0] > 200 && px[1] > 200 && px[2] < 60) yellowSomewhere = true;
}
}
check(yellowSomewhere, "a box picture fills the box");
check(pixel(onPicture, 2, 2)[2] > 200, "and stops at its edge");
bool threw = false;
try {
ParamValues missing = fromPicture;
missing["box_image"] = std::string("/nope/missing.png");
renderText(blue, 200, 120, missing);
} catch (const EffectError&) {
threw = true;
}
check(threw, "a missing box picture is reported");
// Nothing to draw is the state the interface starts in: a pass through.
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("")}}),
solid(200, 120, 0, 0, 255)),
"with no text typed the stage passes its input through");
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("Hi")},
{"text_opacity", 0.0}}),
solid(200, 120, 0, 0, 255)),
"zero opacity leaves the input untouched");
// Like blur and overlay it needs no sources of its own, and the same
// settings render the same bytes twice over.
check(!TextEffect().needsSources(), "text can lead a chain");
const auto alone = renderText({}, 200, 120, plain);
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
"without sources it draws on an empty canvas");
check(sameBytes(renderText(blue, 200, 120, boxed), onBox),
"the same settings render the same bytes");
std::filesystem::remove_all(dir);
}
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");
// 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");
// A stage switched off is skipped whole: it is handed no image and
// draws nothing from the shared rng, so the cascade comes out exactly
// as it would have without that stage in it at all.
ChainStage blurOff = blur;
blurOff.enabled = false;
ChainStage scanlinesOff = scanlines;
scanlinesOff.enabled = false;
Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first
// stage still switched on.
check(!chainNeedsSources({scanlinesOff, blur}),
"a disabled leading stage leaves the question to the stage behind it");
Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead");
bool allOffThrew = false;
try {
Rng rng(1);
renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng);
} catch (const EffectError&) {
allOffThrew = true;
}
check(allOffThrew, "a chain with every stage disabled throws");
// The command line switches a stage off by effect name, the same way
// the params pick out the stage they belong to.
const auto cli =
parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"});
check(cli.has_value(), "--disable is accepted");
const auto cliStages = buildChain(*cli);
check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled,
"--disable keeps the stage in the chain and switches it off");
check(paramInt(cliStages[1].values, "radius", 0) == 4,
"a switched-off stage still takes its flags");
check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(),
"--disable refuses an unknown effect");
}
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");
// A stage switched off keeps its place and its settings in the file and
// comes back switched off; nothing is written for the ordinary case.
Project toggled = project;
toggled.stages[1].enabled = false;
const auto offFile = dir / "disabled.json";
saveProject(offFile.string(), toggled);
const auto offBack = loadProject(offFile.string());
check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled,
"a switched-off stage comes back switched off");
check(paramInt(offBack.stages[1].values, "radius", 0) == 3,
"and with the settings it was switched off with");
check(projectToJson(toggled).find("\"enabled\"") != std::string::npos &&
projectToJson(project).find("\"enabled\"") == std::string::npos,
"only a switched-off stage is written out as one");
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");
// A project is JSON whatever the user's locale is. gtkmm sets both the
// C and the C++ global locale from the environment, so on a German
// desktop an unguarded stream writes "0,35" and a grouped "1.920" —
// which is not JSON, and the project could not be opened again.
const char* const commaLocales[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8",
"fr_FR.utf8"};
bool decimalCommaTried = false;
for (const char* name : commaLocales) {
if (std::setlocale(LC_ALL, name) == nullptr) continue;
std::locale previous;
try {
previous = std::locale::global(std::locale(name));
} catch (const std::exception&) {
continue;
}
decimalCommaTried = true;
Project wide;
wide.width = 1920;
wide.height = 1080;
wide.seed = 17353667313003709502ULL; // more bits than a double holds
wide.stages.push_back({effectByName("grain"), {{"grain_amount", 0.35}}});
const auto text = projectToJson(wide);
bool reread = false;
try {
const auto back = projectFromJson(text);
reread = back.width == 1920 && back.height == 1080 &&
back.seed == wide.seed &&
paramDouble(back.stages[0].values, "grain_amount", -1.0) == 0.35;
} catch (const EffectError&) {
}
check(text.find("0.35") != std::string::npos &&
text.find("\"width\": 1920") != std::string::npos,
std::string("a project is written as JSON under ") + name);
check(reread, std::string("a project written under ") + name + " opens again");
std::locale::global(previous);
std::setlocale(LC_ALL, "C");
break;
}
if (!decimalCommaTried) {
std::cout << " skip a decimal-comma locale (none installed)\n";
}
std::filesystem::remove_all(dir);
}
std::cout << "canvas size\n";
{
// The sizes the front-ends actually offer make the round trip whole.
int kept = 0;
for (const auto [w, h] : {std::pair<int, int>{1, 1},
{640, 400},
{1920, 1080},
{kMaxCanvasSide, kMaxCanvasSide}}) {
const auto back = projectFromJson(
projectWithCanvas(std::to_string(w), std::to_string(h)));
if (back.width == w && back.height == h) ++kept;
}
check(kept == 4, "every canvas in range is read back exactly");
Project project;
project.stages.push_back({effectByName("blur"), {}});
project.width = kMaxCanvasSide;
project.height = 3;
const auto written = projectFromJson(projectToJson(project));
check(written.width == kMaxCanvasSide && written.height == 3,
"the largest canvas survives a write and a read");
// A side that does not fit in an int must not be narrowed into one
// that does: 4294968296 truncates to a perfectly plausible 1000.
int refusals = 0;
for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0",
"20001", "800.5", "\"800\"", "true"}) {
try {
projectFromJson(projectWithCanvas(bad, "600"));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped");
refusals = 0;
for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) {
try {
projectFromJson(projectWithCanvas("800", bad));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 5, "the same bounds apply to the canvas height");
// A canvas the file does not mention keeps the defaults rather than
// failing: an old project is still readable.
const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}");
check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080");
// The CLI narrows the same way, from a long rather than a double.
const auto plain = canvasFromSize("800x600");
check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given");
const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" +
std::to_string(kMaxCanvasSide));
check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas");
check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int");
check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX");
check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long");
check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int");
check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"),
"-s refuses a side past the canvas limit");
check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"),
"-s refuses a side that is not positive");
}
std::cout << "canvas formats\n";
{
using effector::canvasFormatByKey;
using effector::canvasFormats;
using effector::canvasFormatSize;
// Every format the menu offers must land inside the canvas bounds at
// every resolution it can be asked for, or the dropdown would offer a
// size the renderer refuses.
int usable = 0;
for (const auto& format : canvasFormats()) {
int width = 0;
int height = 0;
bool ok = true;
for (const int dpi : {effector::kMinDpi, effector::kDefaultDpi, effector::kMaxDpi}) {
canvasFormatSize(format, dpi, width, height);
ok = ok && width >= kMinCanvasSide && width <= kMaxCanvasSide &&
height >= kMinCanvasSide && height <= kMaxCanvasSide;
}
if (ok) ++usable;
}
check(usable == static_cast<int>(canvasFormats().size()),
"every format stays inside the canvas bounds at every dpi");
int unique = 0;
for (const auto& format : canvasFormats()) {
if (canvasFormatByKey(format.key) == &format) ++unique;
}
check(unique == static_cast<int>(canvasFormats().size()), "format keys are unique");
check(canvasFormatByKey("") == nullptr && canvasFormatByKey("a6") == nullptr,
"an empty or unknown key is a custom canvas");
int width = 0;
int height = 0;
canvasFormatSize(*canvasFormatByKey("a4"), 300, width, height);
check(width == 2480 && height == 3508, "A4 at 300 dpi is 2480x3508");
canvasFormatSize(*canvasFormatByKey("a4"), 600, width, height);
check(width == 4961 && height == 7016, "doubling the dpi doubles the sheet");
canvasFormatSize(*canvasFormatByKey("instagram-story"), 600, width, height);
check(width == 1080 && height == 1920, "a web format ignores the dpi");
// The CLI: a format decides the canvas whichever side of -s it is on,
// and --dpi may come before the format it applies to.
const auto sheet = parsedOptions({"--format", "a4", "--dpi", "600"});
check(sheet && sheet->width == 4961 && sheet->height == 7016,
"--format a4 --dpi 600 sets the canvas");
const auto reordered = parsedOptions({"--dpi", "600", "--format", "a4"});
check(reordered && reordered->width == 4961, "--dpi may be given before --format");
const auto both = parsedOptions({"-s", "800x600", "--format", "instagram-post"});
check(both && both->width == 1080 && both->height == 1080, "--format overrides -s");
const auto custom = parsedOptions({"-s", "800x600"});
check(custom && custom->format.empty() && custom->width == 800,
"-s alone leaves the canvas custom");
check(!parsedOptions({"--format", "a6"}), "--format refuses an unknown format");
check(!parsedOptions({"--dpi", "0"}) && !parsedOptions({"--dpi", "5000"}) &&
!parsedOptions({"--dpi", "many"}),
"--dpi refuses a resolution outside the bounds");
// A project keeps the format, and the format keeps the pixels: a dpi
// edited by hand moves the canvas with it.
Project sheetProject;
sheetProject.stages.push_back({effectByName("blur"), {}});
sheetProject.format = "a4";
sheetProject.dpi = 600;
canvasFormatSize(*canvasFormatByKey("a4"), 600, sheetProject.width, sheetProject.height);
const auto backProject = projectFromJson(projectToJson(sheetProject));
check(backProject.format == "a4" && backProject.dpi == 600 && backProject.width == 4961,
"a print format and its dpi survive a round trip");
const auto retuned = projectFromJson(
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a4\", "
"\"dpi\": 300},\n \"chain\": [{\"effect\": \"blur\"}]}");
check(retuned.width == 2480 && retuned.height == 3508,
"a named format decides the pixels over whatever was written beside it");
// A format this build does not know — a newer file, or a typo — is a
// custom canvas, so the pixels the file carries are still rendered.
const auto unknown = projectFromJson(
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a6\"},\n"
" \"chain\": [{\"effect\": \"blur\"}]}");
check(unknown.format.empty() && unknown.width == 800 && unknown.height == 600,
"an unknown format falls back to the canvas the file names");
Project plainProject;
plainProject.stages.push_back({effectByName("blur"), {}});
plainProject.width = 800;
plainProject.height = 600;
const auto plainText = projectToJson(plainProject);
check(plainText.find("format") == std::string::npos &&
plainText.find("dpi") == std::string::npos,
"a custom canvas writes no format and no dpi");
const auto webText = projectToJson([] {
Project web;
web.stages.push_back({effectByName("blur"), {}});
web.format = "hd";
return web;
}());
check(webText.find("\"format\": \"hd\"") != std::string::npos &&
webText.find("dpi") == std::string::npos,
"a web format writes no dpi it would never use");
int badDpi = 0;
for (const char* bad : {"0", "5000", "300.5", "\"300\""}) {
try {
projectFromJson(std::string("{\"canvas\": {\"dpi\": ") + bad +
"},\n \"chain\": [{\"effect\": \"blur\"}]}");
} catch (const EffectError&) {
++badDpi;
}
}
check(badDpi == 4, "a dpi outside the bounds is refused");
}
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;
}