aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 22:39:18 +0200
committeruser <user@clank>2026-08-14 22:39:18 +0200
commitc0b88416fc9543fa348b63be07905f7a33fdbcc3 (patch)
treef669fdf1066b2ffeed7d61baae572f4834b7ce62 /tests
parentsl0ppty: a tab is something you can name and move (diff)
downloadslosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.gz
slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.tar.xz
slosh-c0b88416fc9543fa348b63be07905f7a33fdbcc3.zip
sl0ppty: a shader's strength can be an expression
Adding an effect meant writing C -- in the binary, or since D15 in a shared library. Both are a lot of ceremony for "dim every other row". So: `amount` may be an expression, compiled to bytecode when the config is read and evaluated per cell. shaders { dim amount="(y % 2) * 40" // scanlines dim amount="(x > cols - 10) * 120" // a right margin tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" } The scope is the argument, and that is the whole design. Every built-in here is already colour_op(amount) with the amount a pure function of position -- vignette, zebra, ruler, margin and spotlight differ only in that function -- so the part worth handing to a config is the *number*, not the colour maths. Three things follow at once: the mixing stays in C where it is fast and correct; an expression cannot produce an invalid cell, because it produces one byte; and that byte is cacheable. The cache is what makes an interpreter affordable, and the numbers are why this exists at all. Measured on a 200x50 pane, one pass: native vignette 6.1 ns/cell constant expression, folded 5.6 ns/cell positional expression, mapped 5.2 ns/cell animated (reads the clock) 35.0 ns/cell A shader you wrote in your config costs what a compiled one does. A program that does not read the clock is evaluated once per cell into a byte map and reused until something it reads changes; one that does read the clock is interpreted per cell, per frame, which is the honest price of animation and is opt-in. (The earlier measurement of 56ns for interpretation was per evaluation -- correct, and the reason the map exists rather than an argument against the language.) What a program reads is derived by the compiler rather than declared, and that dependency set *is* the cache key. A stale map is therefore unrepresentable rather than merely unlikely -- the same rule the layout follows, arrived at from the other direction. A unit test caught the first violation of it before anything else did: `cols` and `rows` carried no dependency flag, on the theory that the key always contains the rect, and so a program reading only the size looked constant and was folded at compile time, where every size is zero. The language is deliberately total. No loops, no recursion, no jumps: even `?:` compiles to evaluating both sides and selecting, because nothing in it has an effect. Evaluation is O(program length), so a config cannot spin, and there is nothing to sandbox. Division by zero is 0 rather than a trap: an expression that is wrong should look wrong, not take the session with it. One that does not compile drops that one shader with a warning -- not the config, and not the half-strength version of what you asked for, which is what the number path did with garbage and is now also fixed. shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing but a rebuild -- the check earning its keep the first time it was asked to.
Diffstat (limited to 'tests')
-rw-r--r--tests/expr_test.c209
-rw-r--r--tests/test_shader_expr.py132
2 files changed, 341 insertions, 0 deletions
diff --git a/tests/expr_test.c b/tests/expr_test.c
new file mode 100644
index 0000000..a202f06
--- /dev/null
+++ b/tests/expr_test.c
@@ -0,0 +1,209 @@
+/* The shader expression compiler and its VM. Pure text in -> numbers out, so
+ * pure tests: no screen, no terminal, no timing. */
+#include "expr.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int fails = 0;
+static char detail[256];
+
+static void ok(const char *name, bool cond, const char *what) {
+ if (!cond) fails++;
+ printf("%s %-56s %s\n", cond ? "ok " : "FAIL", name, cond ? "" : what);
+}
+
+/* Evaluate at a fixed point, so a test says what it means about the maths. */
+static int at(const char *src, int x, int y) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(src, err, sizeof err);
+ if (!p) {
+ snprintf(detail, sizeof detail, "%s: %s", src, err);
+ return -99999;
+ }
+ expr_env_t env = {.x = x, .y = y, .cols = 80, .rows = 24,
+ .curx = 10, .cury = 5, .cursor = 1, .focused = 1,
+ .t = 1000};
+ int v = expr_eval(p, &env);
+ expr_free(p);
+ return v;
+}
+
+static void eq(const char *src, int want) {
+ int got = at(src, 3, 4);
+ if (got != want) snprintf(detail, sizeof detail, "%s -> %d, wanted %d", src, got, want);
+ ok(src, got == want, detail);
+}
+
+static void refused(const char *label, const char *src) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(src, err, sizeof err);
+ if (p) expr_free(p);
+ snprintf(detail, sizeof detail, "%s compiled", src);
+ ok(label, p == NULL && err[0] != 0, detail);
+}
+
+int main(void) {
+ printf("-- arithmetic and precedence\n");
+ eq("1 + 2 * 3", 7);
+ eq("(1 + 2) * 3", 9);
+ eq("10 - 3 - 2", 5); /* left-associative, not 9 */
+ eq("7 % 3", 1);
+ eq("-5 + 1", -4);
+ eq("100 / 7", 14); /* integer division, truncating */
+
+ printf("\n-- division by zero is defined, not a trap\n");
+ eq("5 / 0", 0);
+ eq("5 % 0", 0);
+ eq("1 + 5 / 0", 1);
+
+ printf("\n-- comparisons and logic produce 0 or 1\n");
+ eq("3 < 4", 1);
+ eq("4 < 3", 0);
+ eq("3 <= 3", 1);
+ eq("3 >= 4", 0);
+ eq("3 == 3", 1);
+ eq("3 != 3", 0);
+ eq("1 && 0", 0);
+ eq("1 || 0", 1);
+ eq("!0", 1);
+ eq("!5", 0);
+ /* The reason comparisons return numbers: masking is how you write a rule. */
+ eq("(3 < 4) * 200", 200);
+
+ printf("\n-- variables\n");
+ eq("x", 3);
+ eq("y", 4);
+ eq("cols", 80);
+ eq("rows", 24);
+ eq("curx", 10);
+ eq("cury", 5);
+ eq("cursor", 1);
+ eq("focused", 1);
+ eq("t", 1000);
+
+ printf("\n-- functions\n");
+ eq("min(3, 9)", 3);
+ eq("max(3, 9)", 9);
+ eq("abs(0 - 7)", 7);
+ eq("clamp(300, 0, 255)", 255);
+ eq("clamp(-5, 0, 255)", 0);
+ eq("clamp(100, 0, 255)", 100);
+ /* dist counts a row double, because a cell is about twice as tall as it is
+ * wide. Four columns apart is 4; two rows apart is also 4. */
+ eq("dist(0, 0, 4, 0)", 4);
+ eq("dist(0, 0, 0, 2)", 4);
+
+ printf("\n-- the ternary evaluates both sides and selects\n");
+ eq("1 ? 10 : 20", 10);
+ eq("0 ? 10 : 20", 20);
+ eq("0 ? 1 / 0 : 7", 7); /* the untaken side is safe because nothing traps */
+
+ printf("\n-- an expression that reads nothing is a constant\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("2 * 3 + 1", err, sizeof err);
+ ok("it compiles", p != NULL, err);
+ ok("with no dependencies", p && expr_deps(p) == 0, "deps");
+ ok("folded at compile time", p && expr_constant(p) == 7, "value");
+ expr_free(p);
+ }
+
+ printf("\n-- dependencies are derived from the source, not declared\n");
+ {
+ struct { const char *src; unsigned want; } cases[] = {
+ {"128", 0},
+ {"x * 2", EXPR_DEP_POS},
+ {"y", EXPR_DEP_POS},
+ {"cols / 2", EXPR_DEP_SIZE}, /* not constant: the rect is not known yet */
+ {"dist(x, y, curx, cury)", EXPR_DEP_POS | EXPR_DEP_CURSOR},
+ {"focused * 90", EXPR_DEP_FOCUS},
+ {"t % 1000", EXPR_DEP_TIME},
+ };
+ for (size_t i = 0; i < sizeof cases / sizeof *cases; i++) {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(cases[i].src, err, sizeof err);
+ unsigned got = p ? expr_deps(p) : 0xffff;
+ snprintf(detail, sizeof detail, "%s -> %u, wanted %u", cases[i].src, got,
+ cases[i].want);
+ ok(cases[i].src, p && got == cases[i].want, detail);
+ expr_free(p);
+ }
+ }
+
+ printf("\n-- the map is the whole point: computed once, reused\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("x + y", err, sizeof err);
+ expr_env_t env = {.cols = 8, .rows = 4};
+ uint8_t *m1 = NULL, *m2 = NULL;
+ ok("a positional program has a map", expr_amount_map(p, &env, &m1), err);
+ ok("with the right values", m1 && m1[0] == 0 && m1[8 * 1 + 2] == 3,
+ "values");
+ ok("asking again returns the same buffer",
+ expr_amount_map(p, &env, &m2) && m2 == m1, "buffer");
+
+ /* Resizing must rebuild it: the size is part of what it was computed from,
+ * and a map kept across a resize is the stale-state bug this design is
+ * supposed to make unrepresentable. */
+ env.cols = 4;
+ env.rows = 2;
+ ok("a different rect rebuilds it",
+ expr_amount_map(p, &env, &m2) && m2[4 * 1 + 1] == 2, "rebuild");
+ expr_free(p);
+ }
+
+ printf("\n-- a clock-reading program has nothing to reuse\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("t % 255", err, sizeof err);
+ expr_env_t env = {.cols = 4, .rows = 2};
+ uint8_t *m = NULL;
+ ok("no map is offered", !expr_amount_map(p, &env, &m), "map");
+ expr_free(p);
+ }
+
+ printf("\n-- the cursor is part of the key\n");
+ {
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile("dist(x, y, curx, cury)", err, sizeof err);
+ expr_env_t env = {.cols = 4, .rows = 2, .cursor = 1, .curx = 0, .cury = 0};
+ uint8_t *m = NULL;
+ expr_amount_map(p, &env, &m);
+ int before = m[3];
+ env.curx = 3;
+ expr_amount_map(p, &env, &m);
+ snprintf(detail, sizeof detail, "%d then %d", before, m[3]);
+ ok("moving the cursor rebuilds the map", m[3] != before, detail);
+ expr_free(p);
+ }
+
+ printf("\n-- what is refused, and says why\n");
+ refused("an unknown name", "nosuchvar + 1");
+ refused("an unknown function", "wobble(1, 2)");
+ refused("an unbalanced paren", "(1 + 2");
+ refused("a missing operand", "1 +");
+ refused("trailing junk", "1 + 2 )");
+ refused("an empty expression", "");
+ refused("a ternary without its colon", "1 ? 2");
+ refused("something that is not an expression at all", "@@@");
+
+ printf("\n-- totality: there is no loop to write\n");
+ {
+ /* The language has no way to express unbounded work, so this is a
+ * statement about the grammar rather than a timing test: a deeply nested
+ * expression is bounded by the program length limit and is refused rather
+ * than accepted and run forever. */
+ char big[4096];
+ size_t n = 0;
+ for (int i = 0; i < 200; i++) n += (size_t)snprintf(big + n, sizeof big - n, "1+");
+ snprintf(big + n, sizeof big - n, "1");
+ char err[128] = {0};
+ expr_prog_t *p = expr_compile(big, err, sizeof err);
+ if (p) expr_free(p);
+ ok("an over-long program is refused, not truncated", p == NULL, err);
+ }
+
+ printf("\n%s (%d failures)\n", fails ? "FAILED" : "all green", fails);
+ return fails ? 1 : 0;
+}
diff --git a/tests/test_shader_expr.py b/tests/test_shader_expr.py
new file mode 100644
index 0000000..136026d
--- /dev/null
+++ b/tests/test_shader_expr.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Shaders whose strength is an expression, not a number.
+
+`amount=90` and `amount="(x < 10) * 200"` are the same key and the same idea,
+one of them constant. What is being checked here is that the expression really
+reaches the cells, that a broken one costs its own shader and nothing else,
+and that the states table gets the same treatment as the global chain.
+"""
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+# Prints a full row of known-coloured text, then waits.
+SH = ["/bin/sh", "-c",
+ 'printf "\\033]2;p\\007"; printf "%s\\n" ABCDEFGHIJKLMNOPQRSTUVWXYZ; read x']
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ # A known default_fg, so "what colour is this cell" has one answer.
+ f.write('theme { default_fg "#ffffff" default_bg "#000000" }\n' + text)
+ f.close()
+ return f.name
+
+
+def fg_row(snap, pane, row=0):
+ y = pane["content_y"] + row
+ return [(snap.style_at(pane["content_x"] + x, y) or {}).get("fg")
+ for x in range(pane["content_w"])]
+
+
+def test_an_expression_decides_the_amount_per_cell():
+ c = cfg('shaders {\n dim amount="(x < 10) * 200"\n}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ row = fg_row(snap, s.pane())
+ check("the first ten columns are dimmed",
+ all(c == row[0] and c != "#ffffff" for c in row[:10]), str(row[:12]))
+ check("and the rest are not",
+ all(c == "#ffffff" for c in row[10:20]), str(row[10:20]))
+
+
+def test_a_constant_expression_is_just_a_number():
+ """Folded at compile time, so it has to behave identically to writing it."""
+ a = cfg('shaders {\n dim amount="100 + 28"\n}\n')
+ b = cfg('shaders {\n dim amount=128\n}\n')
+ with Session(SH, cols=44, rows=8, config=a) as s1, \
+ Session(SH, cols=44, rows=8, config=b) as s2:
+ r1 = fg_row(s1.until_text("ABC"), s1.pane())
+ r2 = fg_row(s2.until_text("ABC"), s2.pane())
+ check("an expression that reads nothing matches the number", r1 == r2,
+ f"{r1[:4]} vs {r2[:4]}")
+
+
+def test_the_rect_is_known_to_the_expression():
+ """`cols` is a dependency even though it never varies per cell: it is not
+ known at compile time, and folding it there made every size read as 0."""
+ c = cfg('shaders {\n dim amount="(x > cols - 5) * 200"\n}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ pane = s.pane()
+ row = fg_row(snap, pane)
+ check("the last columns are dimmed, not the first",
+ row[0] == "#ffffff" and row[-1] != "#ffffff",
+ f"{row[0]} .. {row[-1]}")
+
+
+def test_it_follows_a_resize():
+ c = cfg('shaders {\n dim amount="(x > cols - 5) * 200"\n}\n')
+ with Session(SH, cols=60, rows=8, config=c) as s:
+ s.until_text("ABC")
+ s.resize(40, 8)
+ s.settle()
+ snap = s.snapshot()
+ pane = s.pane()
+ row = fg_row(snap, pane)
+ # The cached map has to be rebuilt for the new rect, or the dimmed
+ # band would still be sitting where the old right-hand edge was.
+ check("the band moved with the edge",
+ row[-1] != "#ffffff" and row[len(row) - 8] == "#ffffff",
+ str(row[-10:]))
+
+
+def test_a_broken_expression_costs_only_its_own_shader():
+ c = cfg('shaders {\n'
+ ' dim amount="nosuchthing * 2"\n'
+ ' tint color="#00ff00" amount="255"\n'
+ '}\n')
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ check("the session starts anyway", s.alive())
+ row = fg_row(snap, s.pane())
+ check("and the shader after it still ran", row[0] == "#00ff00",
+ str(row[:3]))
+
+
+def test_states_take_expressions_too():
+ c = cfg('states {\n unfocused { dim amount="(y % 2) * 200" }\n}\n')
+ with Session(SH, cols=80, rows=16, config=c) as s:
+ s.until_text("ABC")
+ s.key("\\\\") # split; the left pane loses focus
+ s.settle()
+ panes = s.panes()
+ unfocused = [p for p in panes if not p["focused"]][0]
+ snap = s.snapshot()
+ r0 = fg_row(snap, unfocused, 0)
+ r1 = fg_row(snap, unfocused, 1)
+ check("even rows are left alone", r0[0] == "#ffffff", str(r0[:3]))
+ check("odd rows are dimmed", r1[0] != "#ffffff" and r1[0] is not None,
+ str(r1[:3]))
+
+
+def test_an_expression_cannot_hang_the_session():
+ """There is no loop to write, so the worst case is a long expression --
+ and one longer than the program limit is refused rather than run."""
+ long_expr = "+".join(["x"] * 300)
+ c = cfg('shaders {\n dim amount="%s"\n}\n' % long_expr)
+ with Session(SH, cols=44, rows=8, config=c) as s:
+ snap = s.until_text("ABC")
+ check("the session is alive and drawing", s.alive())
+ # Unstyled, not merely undimmed: with its only shader refused there
+ # is no pass at all, so the cells still defer to the terminal.
+ check("and the refused shader did nothing",
+ fg_row(snap, s.pane())[0] in (None, "#ffffff"),
+ str(fg_row(snap, s.pane())[:3]))
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())