-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. -- Exercises the term.gfx graphics base: the C surface (pixel/get, fill, -- rect, blit with clipping/blend/flip/tint, resize, tostring), and the Lua -- layers built on top (canvas, sprite/art, font). These are pure-compute -- checks — no terminal needed. local testimony = require("testimony") local core = require("term.gfx_core") local gfx = require("term.gfx") local A = testimony local testify = testimony.new("== term.gfx surface ==") -- helper: pack a colour for comparison local function px(s, x, y) local r, g, b, a = s:get(x, y) return string.format("%d,%d,%d,%d", r, g, b, a) end testify:that("surface: dimensions and transparent init", function() local s = core.surface(8, 4) A.assert_equal(8, s:width()) A.assert_equal(4, s:height()) A.assert_equal("0,0,0,0", px(s, 1, 1)) A.assert_equal("0,0,0,0", px(s, 8, 4)) end) testify:that("surface: tostring length == w*h*4", function() local s = core.surface(10, 7) A.assert_equal(10 * 7 * 4, #s:tostring()) end) testify:that("surface: pixel set/get round-trips, 1-indexed", function() local s = core.surface(4, 4) s:pixel(1, 1, 10, 20, 30, 40) s:pixel(4, 4, 1, 2, 3, 4) A.assert_equal("10,20,30,40", px(s, 1, 1)) A.assert_equal("1,2,3,4", px(s, 4, 4)) end) testify:that("surface: get out of bounds returns nil", function() local s = core.surface(4, 4) A.assert_nil(s:get(0, 1)) A.assert_nil(s:get(5, 1)) A.assert_nil(s:get(1, 5)) end) testify:that("surface: pixel off-surface is a silent no-op", function() local s = core.surface(4, 4) s:pixel(0, 0, 99, 99, 99, 99) -- must not crash or write s:pixel(100, 100, 99, 99, 99, 99) A.assert_equal("0,0,0,0", px(s, 1, 1)) end) testify:that("surface: fill floods every pixel", function() local s = core.surface(3, 3) s:fill(5, 6, 7, 8) A.assert_equal("5,6,7,8", px(s, 1, 1)) A.assert_equal("5,6,7,8", px(s, 3, 3)) s:clear() A.assert_equal("0,0,0,0", px(s, 2, 2)) end) testify:that("surface: filled rect, clipped to bounds", function() local s = core.surface(5, 5) -- rect partly off the top-left; only the in-bounds part is painted s:rect(-1, -1, 2, 2, 100, 0, 0, 255, true) A.assert_equal("100,0,0,255", px(s, 1, 1)) A.assert_equal("100,0,0,255", px(s, 2, 2)) A.assert_equal("0,0,0,0", px(s, 3, 3)) end) testify:that("surface: outline rect leaves interior empty", function() local s = core.surface(5, 5) s:rect(1, 1, 5, 5, 0, 255, 0, 255, false) A.assert_equal("0,255,0,255", px(s, 1, 1)) -- corner A.assert_equal("0,255,0,255", px(s, 3, 1)) -- top edge A.assert_equal("0,0,0,0", px(s, 3, 3)) -- interior end) -- ── blit ──────────────────────────────────────────────────────────────── testify:that("blit: replace copies opaque pixels", function() local dst = core.surface(6, 6) local src = core.surface(2, 2) src:fill(10, 20, 30, 255) dst:blit(src, 2, 2, 1, 1, 2, 2, 0, 255, 255, 255, 255, 255, 0) -- blend=replace A.assert_equal("10,20,30,255", px(dst, 2, 2)) A.assert_equal("10,20,30,255", px(dst, 3, 3)) A.assert_equal("0,0,0,0", px(dst, 1, 1)) A.assert_equal("0,0,0,0", px(dst, 4, 4)) end) testify:that("blit: clips at all four edges without error", function() local dst = core.surface(4, 4) local src = core.surface(4, 4) src:fill(7, 7, 7, 255) -- src top-left placed at dst (-2,-2): a 4x4 source shifted 3 cells (0-indexed) -- left/up overlaps dst in only its single (1,1) corner. dst:blit(src, -2, -2, 1, 1, 4, 4, 0, 255, 255, 255, 255, 255, 0) -- top-left overhang A.assert_equal("7,7,7,255", px(dst, 1, 1)) A.assert_equal("0,0,0,0", px(dst, 2, 2)) dst:clear() dst:blit(src, 3, 3, 1, 1, 4, 4, 0, 255, 255, 255, 255, 255, 0) -- bottom-right overhang A.assert_equal("7,7,7,255", px(dst, 4, 4)) A.assert_equal("0,0,0,0", px(dst, 2, 2)) end) testify:that("blit: alpha blend mixes over destination (50%)", function() local dst = core.surface(2, 2) dst:fill(0, 0, 0, 255) local src = core.surface(2, 2) src:fill(200, 200, 200, 128) -- ~50% opacity dst:blit(src, 1, 1) -- defaults: alpha blend -- out = (200*128 + 0*127)/255 = 100 A.assert_equal("100,100,100,255", px(dst, 1, 1)) end) testify:that("blit: tint multiplies source channels", function() local dst = core.surface(1, 1) local src = core.surface(1, 1) src:fill(255, 255, 255, 255) -- white mask -- tint to pure red via canvas-level call below; here raw: tr=255,tg=0,tb=0 dst:blit(src, 1, 1, 1, 1, 1, 1, 0, 255, 0, 0, 255, 255, 0) -- replace, red tint A.assert_equal("255,0,0,255", px(dst, 1, 1)) end) testify:that("blit: horizontal flip mirrors source columns", function() local dst = core.surface(2, 1) local src = core.surface(2, 1) src:pixel(1, 1, 1, 0, 0, 255) src:pixel(2, 1, 2, 0, 0, 255) dst:blit(src, 1, 1, 1, 1, 2, 1, 1, 255, 255, 255, 255, 255, 0) -- flip h, replace A.assert_equal("2,0,0,255", px(dst, 1, 1)) A.assert_equal("1,0,0,255", px(dst, 2, 1)) end) testify:that("blit: scaled (dw,dh) nearest-neighbour upscales 2x2 -> 4x4", function() local dst = core.surface(4, 4) local src = core.surface(2, 2) src:pixel(1, 1, 10, 0, 0, 255) -- A (top-left) src:pixel(2, 1, 20, 0, 0, 255) -- B (top-right) src:pixel(1, 2, 30, 0, 0, 255) -- C (bottom-left) src:pixel(2, 2, 40, 0, 0, 255) -- D (bottom-right) -- replace blend, full source, dest 4x4 -> each source pixel fills a 2x2 block dst:blit(src, 1, 1, 1, 1, 2, 2, 0, 255, 255, 255, 255, 255, 0, 4, 4) A.assert_equal("10,0,0,255", px(dst, 1, 1)) -- A block A.assert_equal("10,0,0,255", px(dst, 2, 2)) A.assert_equal("20,0,0,255", px(dst, 3, 1)) -- B block A.assert_equal("20,0,0,255", px(dst, 4, 2)) A.assert_equal("30,0,0,255", px(dst, 1, 4)) -- C block A.assert_equal("40,0,0,255", px(dst, 4, 4)) -- D block end) testify:that("blit: dw==sw,dh==sh is the bit-for-bit 1:1 path", function() local src = core.surface(3, 2) src:fill(50, 60, 70, 255) src:pixel(2, 1, 1, 2, 3, 255) local a = core.surface(3, 2) local b = core.surface(3, 2) a:blit(src, 1, 1) -- implicit dest size b:blit(src, 1, 1, 1, 1, 3, 2, 0, 255, 255, 255, 255, 255, 1, 3, 2) -- explicit dw,dh A.assert_equal(a:tostring(), b:tostring()) end) testify:that("canvas: blit with opts.scale upscales", function() local c = gfx.new_canvas({ width = 8, height = 8 }) local src = gfx.new_canvas({ width = 2, height = 2 }) src:fill({ 5, 6, 7, 255 }) c:blit(src, 1, 1, { blend = "replace", scale = 2 }) -- 2x2 -> 4x4 A.assert_equal("5,6,7,255", px(c.__state.surface, 1, 1)) A.assert_equal("5,6,7,255", px(c.__state.surface, 4, 4)) -- filled out to 4x4 A.assert_equal("0,0,0,0", px(c.__state.surface, 5, 5)) -- nothing beyond end) testify:that("blit: rot=90° maps a horizontal strip to a vertical one (clockwise)", function() -- source strip A,B,C left→right; rotated +90° (clockwise on screen, y-down) -- around the rect centre it reads A,B,C top→bottom. local src = core.surface(3, 1) src:pixel(1, 1, 10, 0, 0, 255) -- A src:pixel(2, 1, 20, 0, 0, 255) -- B src:pixel(3, 1, 30, 0, 0, 255) -- C local dst = core.surface(6, 6) dst:blit(src, 2, 2, 1, 1, 3, 1, 0, 255, 255, 255, 255, 255, 0, 3, 1, math.pi / 2) A.assert_equal("10,0,0,255", px(dst, 3, 1)) -- A on top A.assert_equal("20,0,0,255", px(dst, 3, 2)) -- B centre A.assert_equal("30,0,0,255", px(dst, 3, 3)) -- C at bottom A.assert_equal("0,0,0,0", px(dst, 2, 2)) -- nothing beside the column end) testify:that("blit: rot=180° equals a horizontal flip for a 1-row strip", function() local src = gfx.new_canvas({ width = 3, height = 1 }) src:pixel(1, 1, { 10, 0, 0, 255 }) src:pixel(2, 1, { 20, 0, 0, 255 }) src:pixel(3, 1, { 30, 0, 0, 255 }) local a = gfx.new_canvas({ width = 6, height = 6 }) local b = gfx.new_canvas({ width = 6, height = 6 }) a:blit(src, 2, 2, { blend = "replace", rot = math.pi }) b:blit(src, 2, 2, { blend = "replace", flip = "h" }) for x = 2, 4 do A.assert_equal(px(b.__state.surface, x, 2), px(a.__state.surface, x, 2)) end end) testify:that("blit: rot composes with dw/dh scaling and clips safely", function() local src = gfx.new_canvas({ width = 2, height = 2 }) src:fill({ 7, 8, 9, 255 }) local c = gfx.new_canvas({ width = 8, height = 8 }) -- scaled 2x and rotated 45°: centre pixel of the rotated square must land c:blit(src, 3, 3, { blend = "replace", dw = 4, dh = 4, rot = math.pi / 4 }) A.assert_equal("7,8,9,255", px(c.__state.surface, 5, 5)) -- rect centre (unmoved by rot) -- hanging off every edge must not error c:blit(src, -3, -3, { dw = 10, dh = 10, rot = 1.1 }) c:blit(src, 7, 7, { dw = 10, dh = 10, rot = 2.3 }) end) testify:that("canvas: poly fills a convex triangle (and only it)", function() local c = gfx.new_canvas({ width = 10, height = 10 }) c:poly({ { x = 2, y = 2 }, { x = 8, y = 2 }, { x = 2, y = 8 } }, { 1, 2, 3, 255 }) A.assert_equal("1,2,3,255", px(c.__state.surface, 3, 3)) -- inside A.assert_equal("1,2,3,255", px(c.__state.surface, 7, 2)) -- on the top edge A.assert_equal("0,0,0,0", px(c.__state.surface, 8, 8)) -- outside the hypotenuse end) testify:that("canvas: ellipse draws an outline through its extremes", function() local c = gfx.new_canvas({ width = 20, height = 20 }) c:ellipse(10, 10, 6, 3, { 9, 9, 9, 255 }) A.assert_equal("9,9,9,255", px(c.__state.surface, 16, 10)) -- +rx A.assert_equal("9,9,9,255", px(c.__state.surface, 10, 13)) -- +ry A.assert_equal("0,0,0,0", px(c.__state.surface, 10, 10)) -- centre stays empty end) testify:that("surface: resize preserves top-left overlap", function() local s = core.surface(4, 4) s:fill(9, 9, 9, 255) s:resize(2, 6) A.assert_equal(2, s:width()) A.assert_equal(6, s:height()) A.assert_equal("9,9,9,255", px(s, 1, 1)) -- kept A.assert_equal("0,0,0,0", px(s, 1, 5)) -- new area is transparent end) testify:that("surface: invalid dimensions raise", function() A.assert_error(function() core.surface(0, 10) end) A.assert_error(function() core.surface(10, -1) end) end) -- ── Lua layers ─────────────────────────────────────────────────────────── testify:that("canvas: rect via {r,g,b,a} colour, get reads back", function() local c = gfx.new_canvas({ width = 8, height = 8, colors = { bg = { 0, 0, 0, 0 } } }) c:rect(2, 2, 4, 4, { 11, 22, 33, 255 }, true) A.assert_equal("11,22,33,255", px(c.__state.surface, 3, 3)) end) testify:that("canvas: blit a sprite onto the canvas", function() local c = gfx.new_canvas({ width = 8, height = 8 }) local src = gfx.new_canvas({ width = 2, height = 2 }) src:fill({ 50, 60, 70, 255 }) c:blit(src, 1, 1) A.assert_equal("50,60,70,255", px(c.__state.surface, 1, 1)) end) testify:that("art: rasterizes a char-map at scale", function() -- 2x1 art, scale 2 -> a 4x2 surface; left cell red, right cell empty local sp = gfx.art({ rows = { "R." }, palette = { R = { 255, 0, 0, 255 } }, scale = 2 }) A.assert_equal(4, sp.w) A.assert_equal(2, sp.h) A.assert_equal("255,0,0,255", px(sp.surface, 1, 1)) A.assert_equal("255,0,0,255", px(sp.surface, 2, 2)) A.assert_equal("0,0,0,0", px(sp.surface, 3, 1)) -- the "." cell end) testify:that("font: draws a glyph mask tinted to the draw colour", function() local f = gfx.font({}) -- default 5x7 ASCII local c = gfx.new_canvas({ width = 40, height = 10 }) local xend = f:draw(c, "A", 1, 1, { scale = 1, color = { 0, 200, 0, 255 } }) A.assert_equal(1 + 5, xend) -- one 5px-wide glyph -- 'A' default glyph row 1 is ".###." -> pixel (1,1) empty, (2,1) lit (tinted green) A.assert_equal("0,0,0,0", px(c.__state.surface, 1, 1)) A.assert_equal("0,200,0,255", px(c.__state.surface, 2, 1)) end) testify:that("anim: advances frames by dt and reports done", function() local a = gfx.new_canvas({ width = 1, height = 1 }) local b = gfx.new_canvas({ width = 1, height = 1 }) a:fill({ 1, 0, 0, 255 }) b:fill({ 2, 0, 0, 255 }) local an = gfx.anim({ frames = { gfx.sprite(a), gfx.sprite(b) }, durations = 0.1, loop = false, }) A.assert_equal(a.__state.surface, an:current().surface) an:update(0.15) -- cross into frame 2 A.assert_equal(b.__state.surface, an:current().surface) an:update(0.2) -- past the end, non-looping A.assert_true(an:done()) end) testify:that("alloc_id: returns distinct ids", function() local a = gfx.alloc_id() local b = gfx.alloc_id() A.assert_true(a ~= b) end) testify:that("loop: runs the fixed-timestep render loop and stops cleanly", function() -- Headless: no alt screen, no input, no display -> no terminal needed. local c = gfx.new_canvas({ width = 4, height = 4 }) local renders, updates = 0, 0 local h h = gfx.loop({ fps = 200, -- fast so the test finishes quickly alt_screen = false, canvas = c, update = function(_) updates = updates + 1 end, render = function(_) renders = renders + 1 if renders >= 3 then h:stop() -- stop from inside the callback (reentrant-safe) end end, }) h:run() -- owns lev.run; blocks until stopped A.assert_true(renders >= 3) A.assert_true(updates >= 1) A.assert_false(h:is_running()) end) testify:conclude()