esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit ec21c9cab3d96e53c1a678576770a3d33dd3297d
parent a826e62679a2bc07ce1479c32a6dbf0c28cb329f
Author: Marc Coquand <marc@coquand.email>
Date:   Wed, 25 Feb 2026 15:07:05 +0100

Add tests and makefile

Diffstat:
AMakefile | 35+++++++++++++++++++++++++++++++++++
Meditor.c | 1+
Atests/test_editor | 0
Atests/test_editor.c | 524+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_harness.h | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_strbuf | 0
Atests/test_strbuf.c | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 779 insertions(+), 0 deletions(-)
diff --git a/Makefile b/Makefile
@@ -0,0 +1,35 @@
+CC      = cc
+CFLAGS  = -Wall -Wextra -g
+CFLAGS_OPT = -O3
+SANITIZE = -fsanitize=address,undefined
+SDL3_CFLAGS = $(shell pkg-config --cflags sdl3)
+SDL3_LIBS   = $(shell pkg-config --libs sdl3)
+
+.PHONY: all test clean
+
+all: esc
+
+esc: main.c unix_utils.c unix_utils.h editor.c editor.h \
+     strbuf.c strbuf.h fuse_ipc.c fuse_ipc.h renderer.c renderer.h
+	$(CC) $(CFLAGS) $(CFLAGS_OPT) main.c unix_utils.c editor.c strbuf.c \
+	      fuse_ipc.c renderer.c -o esc \
+	      $(shell pkg-config --cflags --libs sdl3 sdl3-ttf fuse)
+
+test: tests/test_strbuf tests/test_editor
+	@echo "--- running test_strbuf ---"
+	ASAN_OPTIONS=detect_leaks=1 tests/test_strbuf
+	@echo "--- running test_editor ---"
+	ASAN_OPTIONS=detect_leaks=1 tests/test_editor
+	@echo "All tests passed."
+
+tests/test_strbuf: tests/test_strbuf.c tests/test_harness.h strbuf.c strbuf.h
+	$(CC) $(CFLAGS) $(SANITIZE) tests/test_strbuf.c strbuf.c -o tests/test_strbuf
+
+tests/test_editor: tests/test_editor.c tests/test_harness.h \
+                   editor.c editor.h strbuf.c strbuf.h
+	$(CC) $(CFLAGS) $(SANITIZE) $(SDL3_CFLAGS) \
+	      tests/test_editor.c editor.c strbuf.c \
+	      $(SDL3_LIBS) -o tests/test_editor
+
+clean:
+	rm -f esc tests/test_strbuf tests/test_editor
diff --git a/editor.c b/editor.c
@@ -152,6 +152,7 @@ void editor_destroy(Editor *ed) {
 	for (int i = 0; i < ed->redo_len; i++)
 		free(ed->redo_stack[i].reinsert);
 	free(ed->redo_stack);
+	free(ed->ranges);
 	free(ed);
 }
 
diff --git a/tests/test_editor b/tests/test_editor
Binary files differ.
diff --git a/tests/test_editor.c b/tests/test_editor.c
@@ -0,0 +1,524 @@
+#include "../editor.h"
+#include "../strbuf.h"
+#include "test_harness.h"
+#include <SDL3/SDL.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---- suite_create_destroy ----------------------------------------------- */
+
+static void suite_create_destroy(void) {
+	RUN_SUITE(suite_create_destroy);
+
+	Editor *ed = editor_create(8, 16.0f);
+	ASSERT_NOT_NULL(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+	ASSERT_EQ_INT(ed->selection_anchor, 0);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+	editor_destroy(ed);
+}
+
+/* ---- suite_insert_text -------------------------------------------------- */
+
+static void suite_insert_text(void) {
+	RUN_SUITE(suite_insert_text);
+
+	Editor *ed = editor_create(8, 16.0f);
+
+	editor_insert_text(ed, "hello", false);
+	ASSERT_EQ_INT((int)ed->text.len, 5);
+	ASSERT_EQ_INT(ed->cursor_idx, 5);
+	ASSERT_EQ_STR(ed->text.data, "hello");
+
+	/* inserting a second string appends (cursor is at end) */
+	editor_insert_text(ed, " world", false);
+	ASSERT_EQ_INT((int)ed->text.len, 11);
+	ASSERT_EQ_STR(ed->text.data, "hello world");
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_delete_back -------------------------------------------------- */
+
+static void suite_delete_back(void) {
+	RUN_SUITE(suite_delete_back);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab", false);
+
+	/* delete 'b' */
+	editor_delete_back(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 1);
+	ASSERT_EQ_STR(ed->text.data, "a");
+	ASSERT_EQ_INT(ed->cursor_idx, 1);
+
+	/* delete 'a' */
+	editor_delete_back(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	/* no-op at pos 0 */
+	editor_delete_back(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_delete_forward ----------------------------------------------- */
+
+static void suite_delete_forward(void) {
+	RUN_SUITE(suite_delete_forward);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab", false);
+	editor_goto_pos(ed, 0);
+	editor_clear_selection(ed);
+
+	/* delete 'a' */
+	editor_delete_forward(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 1);
+	ASSERT_EQ_STR(ed->text.data, "b");
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	/* delete 'b' */
+	editor_delete_forward(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+
+	/* no-op at end */
+	editor_delete_forward(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_delete_multibyte --------------------------------------------- */
+
+static void suite_delete_multibyte(void) {
+	RUN_SUITE(suite_delete_multibyte);
+
+	/* "aé" = 'a'(1) + \xc3\xa9(2) = 3 bytes total */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "a\xc3\xa9", false); /* cursor at byte 3 */
+	ASSERT_EQ_INT(ed->cursor_idx, 3);
+
+	/* delete_back over 2-byte é: cursor should move back 2 bytes */
+	editor_delete_back(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 1);
+	ASSERT_EQ_INT((int)ed->text.len, 1);
+	ASSERT_EQ_STR(ed->text.data, "a");
+
+	editor_destroy(ed);
+
+	/* delete_forward over single ASCII byte */
+	ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab", false);
+	editor_goto_pos(ed, 0);
+	editor_clear_selection(ed);
+	editor_delete_forward(ed); /* deletes 'a' (1 byte) */
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+	ASSERT_EQ_INT((int)ed->text.len, 1);
+	ASSERT_EQ_STR(ed->text.data, "b");
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_cursor_movement ---------------------------------------------- */
+
+static void suite_cursor_movement(void) {
+	RUN_SUITE(suite_cursor_movement);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab\ncd", false); /* bytes: a=0 b=1 \n=2 c=3 d=4 */
+
+	/* start at end (byte 5), move left one by one */
+	ASSERT_EQ_INT(ed->cursor_idx, 5);
+	editor_cursor_left(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 4);
+	editor_cursor_left(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 3);
+	editor_cursor_left(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 2); /* the '\n' */
+	editor_cursor_left(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 1);
+	editor_cursor_left(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+	editor_cursor_left(ed); /* no-op at start */
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	/* move right back to end */
+	editor_cursor_right(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 1);
+	editor_cursor_right(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 2);
+	editor_cursor_right(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 3);
+	editor_cursor_right(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 4);
+	editor_cursor_right(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 5);
+	editor_cursor_right(ed); /* no-op at end */
+	ASSERT_EQ_INT(ed->cursor_idx, 5);
+
+	/* cursor_up from start of second line (byte 3) -> goes to byte 0 */
+	editor_goto_pos(ed, 3);
+	editor_cursor_up(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	/* cursor_down from byte 0 -> goes to byte 3 */
+	editor_goto_pos(ed, 0);
+	editor_cursor_down(ed);
+	ASSERT_EQ_INT(ed->cursor_idx, 3);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_select_all --------------------------------------------------- */
+
+static void suite_select_all(void) {
+	RUN_SUITE(suite_select_all);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "hello", false);
+
+	editor_select_all(ed);
+	ASSERT_EQ_INT(ed->selection_anchor, 0);
+	ASSERT_EQ_INT(ed->cursor_idx, 5);
+	ASSERT(editor_has_selection(ed));
+
+	char *sel = editor_get_selection(ed);
+	ASSERT_NOT_NULL(sel);
+	ASSERT_EQ_STR(sel, "hello");
+	free(sel);
+
+	editor_clear_selection(ed);
+	ASSERT(!editor_has_selection(ed));
+	sel = editor_get_selection(ed);
+	ASSERT_NULL(sel);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_replace_body ------------------------------------------------- */
+
+static void suite_replace_body(void) {
+	RUN_SUITE(suite_replace_body);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "old content", false);
+
+	editor_replace_body(ed, "new", 3);
+	ASSERT_EQ_INT((int)ed->text.len, 3);
+	ASSERT_EQ_STR(ed->text.data, "new");
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	/* NULL data is safe */
+	editor_replace_body(ed, NULL, 0);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+	ASSERT_EQ_INT(ed->cursor_idx, 0);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_undo_redo ---------------------------------------------------- */
+
+static void suite_undo_redo(void) {
+	RUN_SUITE(suite_undo_redo);
+
+	Editor *ed = editor_create(8, 16.0f);
+
+	editor_insert_text(ed, "hello", false);
+	editor_insert_text(ed, " world", false);
+	ASSERT_EQ_STR(ed->text.data, "hello world");
+	ASSERT_EQ_INT(ed->undo_len, 2);
+	ASSERT_EQ_INT(ed->redo_len, 0);
+
+	/* undo removes " world" */
+	editor_undo(ed);
+	ASSERT_EQ_STR(ed->text.data, "hello");
+	ASSERT_EQ_INT(ed->undo_len, 1);
+	ASSERT_EQ_INT(ed->redo_len, 1);
+
+	/* undo removes "hello" */
+	editor_undo(ed);
+	ASSERT_EQ_INT((int)ed->text.len, 0);
+	ASSERT_EQ_INT(ed->undo_len, 0);
+	ASSERT_EQ_INT(ed->redo_len, 2);
+
+	/* undo when empty is a no-op */
+	editor_undo(ed);
+	ASSERT_EQ_INT(ed->undo_len, 0);
+
+	/* redo restores "hello" */
+	editor_redo(ed);
+	ASSERT_EQ_STR(ed->text.data, "hello");
+	ASSERT_EQ_INT(ed->redo_len, 1);
+
+	/* new edit clears redo stack */
+	editor_insert_text(ed, "!", false);
+	ASSERT_EQ_INT(ed->redo_len, 0);
+	ASSERT_EQ_STR(ed->text.data, "hello!");
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_insert_replace_selection ------------------------------------- */
+
+static void suite_insert_replace_selection(void) {
+	RUN_SUITE(suite_insert_replace_selection);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "hello", false);
+
+	/* select all then replace */
+	editor_select_all(ed);
+	ASSERT(editor_has_selection(ed));
+
+	editor_insert_text(ed, "world", true);
+	ASSERT_EQ_STR(ed->text.data, "world");
+	ASSERT_EQ_INT((int)ed->text.len, 5);
+	ASSERT(!editor_has_selection(ed));
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_formatting_range --------------------------------------------- */
+
+static void suite_formatting_range(void) {
+	RUN_SUITE(suite_formatting_range);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "abcde", false);
+
+	RangeFormat fmt = {.bold = true, .italic = false};
+	editor_add_formatting_range(ed, 1, 3, fmt); /* chars 1..3 = bytes 1..3 */
+
+	/* inside range: bold=true */
+	RangeFormat f = editor_get_format_at(ed, 1);
+	ASSERT(f.bold == true);
+	f = editor_get_format_at(ed, 2);
+	ASSERT(f.bold == true);
+
+	/* outside range: bold=false */
+	f = editor_get_format_at(ed, 0);
+	ASSERT(f.bold == false);
+	f = editor_get_format_at(ed, 3); /* end is exclusive */
+	ASSERT(f.bold == false);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_replace_range ------------------------------------------------ */
+
+static void suite_replace_range(void) {
+	RUN_SUITE(suite_replace_range);
+
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "abcde", false);
+
+	editor_add_replace_range(ed, 1, 3, 2); /* chars 1..3 -> 2 visual cols */
+
+	/* byte 1 is inside the replacement range */
+	EditorRange *r = editor_get_range_at(ed, 1);
+	ASSERT_NOT_NULL(r);
+	ASSERT_EQ_INT(r->type, RANGE_REPLACEMENT);
+	ASSERT_EQ_INT(r->data.replacement.visual_cols, 2);
+
+	/* byte 0 is before the range */
+	ASSERT_NULL(editor_get_range_at(ed, 0));
+
+	/* byte 3 is the exclusive end, not inside */
+	ASSERT_NULL(editor_get_range_at(ed, 3));
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_parse_ansi_codes --------------------------------------------- */
+
+static void suite_parse_ansi_codes(void) {
+	RUN_SUITE(suite_parse_ansi_codes);
+
+	Editor *ed = editor_create(8, 16.0f);
+
+	/*
+	 * "\x1b[1m"  = ESC [ 1 m  = 4 bytes  -> replacement range 0..4
+	 * "hi"                     = 2 bytes  -> bold format range 4..6
+	 * "\x1b[0m"  = ESC [ 0 m  = 4 bytes  -> replacement range 6..10
+	 */
+	editor_insert_text(ed, "\x1b[1mhi\x1b[0m", false);
+	ASSERT_EQ_INT((int)ed->text.len, 10);
+
+	editor_parse_ansi_codes(ed);
+
+	/* The ESC sequence at byte 0 should be a replacement range */
+	EditorRange *r = editor_get_range_at(ed, 0);
+	ASSERT_NOT_NULL(r);
+	ASSERT_EQ_INT(r->type, RANGE_REPLACEMENT);
+
+	/* "hi" at bytes 4-5 should have bold formatting */
+	RangeFormat f = editor_get_format_at(ed, 4);
+	ASSERT(f.bold == true);
+	f = editor_get_format_at(ed, 5);
+	ASSERT(f.bold == true);
+
+	/* After the closing sequence, bold should be off */
+	f = editor_get_format_at(ed, 6);
+	ASSERT(f.bold == false);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_byte_to_visual_pos ------------------------------------------ */
+
+static void suite_byte_to_visual_pos(void) {
+	RUN_SUITE(suite_byte_to_visual_pos);
+
+	/* text "ab\ncd": bytes a=0 b=1 \n=2 c=3 d=4 */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab\ncd", false);
+
+	VisualPos p;
+
+	p = editor_byte_to_visual_pos(ed, 0);
+	ASSERT_EQ_INT(p.row, 0); ASSERT_EQ_INT(p.col, 0);
+
+	p = editor_byte_to_visual_pos(ed, 1);
+	ASSERT_EQ_INT(p.row, 0); ASSERT_EQ_INT(p.col, 1);
+
+	p = editor_byte_to_visual_pos(ed, 2);
+	ASSERT_EQ_INT(p.row, 0); ASSERT_EQ_INT(p.col, 2);
+
+	p = editor_byte_to_visual_pos(ed, 3);
+	ASSERT_EQ_INT(p.row, 1); ASSERT_EQ_INT(p.col, 0);
+
+	p = editor_byte_to_visual_pos(ed, 4);
+	ASSERT_EQ_INT(p.row, 1); ASSERT_EQ_INT(p.col, 1);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_visual_pos_to_byte ------------------------------------------ */
+
+static void suite_visual_pos_to_byte(void) {
+	RUN_SUITE(suite_visual_pos_to_byte);
+
+	/* text "ab\ncd" */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "ab\ncd", false);
+
+	ASSERT_EQ_INT(editor_visual_pos_to_byte(ed, (VisualPos){0, 0}), 0);
+	ASSERT_EQ_INT(editor_visual_pos_to_byte(ed, (VisualPos){0, 1}), 1);
+	ASSERT_EQ_INT(editor_visual_pos_to_byte(ed, (VisualPos){1, 0}), 3);
+	ASSERT_EQ_INT(editor_visual_pos_to_byte(ed, (VisualPos){1, 1}), 4);
+
+	/* col beyond line end clamps to end-of-line ('\n' at byte 2) */
+	ASSERT_EQ_INT(editor_visual_pos_to_byte(ed, (VisualPos){0, 99}), 2);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_tab_expansion ------------------------------------------------ */
+
+static void suite_tab_expansion(void) {
+	RUN_SUITE(suite_tab_expansion);
+
+	/* "\ta": '\t' at byte 0, 'a' at byte 1.
+	 * TAB_SIZE=8: the tab expands to 8 columns, so 'a' is at visual col 8. */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "\ta", false);
+
+	VisualPos p = editor_byte_to_visual_pos(ed, 1);
+	ASSERT_EQ_INT(p.col, TAB_SIZE);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_select_word -------------------------------------------------- */
+
+static void suite_select_word(void) {
+	RUN_SUITE(suite_select_word);
+
+	/* text "foo bar" */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "foo bar", false);
+
+	/* cursor inside "foo" (byte 1) -> select "foo" */
+	editor_goto_pos(ed, 1);
+	editor_clear_selection(ed);
+	editor_select_word(ed);
+	char *sel = editor_get_selection(ed);
+	ASSERT_NOT_NULL(sel);
+	ASSERT_EQ_STR(sel, "foo");
+	free(sel);
+
+	/* cursor inside "bar" (byte 5) -> select "bar" */
+	editor_goto_pos(ed, 5);
+	editor_clear_selection(ed);
+	editor_select_word(ed);
+	sel = editor_get_selection(ed);
+	ASSERT_NOT_NULL(sel);
+	ASSERT_EQ_STR(sel, "bar");
+	free(sel);
+
+	editor_destroy(ed);
+}
+
+/* ---- suite_range_shifts_on_insert --------------------------------------- */
+
+static void suite_range_shifts_on_insert(void) {
+	RUN_SUITE(suite_range_shifts_on_insert);
+
+	/* Insert "abcd", add bold formatting at chars 2..4 (bytes 2..4). */
+	Editor *ed = editor_create(8, 16.0f);
+	editor_insert_text(ed, "abcd", false);
+
+	RangeFormat fmt = {.bold = true, .italic = false};
+	editor_add_formatting_range(ed, 2, 4, fmt);
+
+	/* Verify bold is at byte 2 before insert */
+	RangeFormat f = editor_get_format_at(ed, 2);
+	ASSERT(f.bold == true);
+	f = editor_get_format_at(ed, 0);
+	ASSERT(f.bold == false);
+
+	/* Insert "XY" at beginning: cursor to 0, then insert */
+	editor_goto_pos(ed, 0);
+	editor_clear_selection(ed);
+	editor_insert_text(ed, "XY", false);
+
+	/* Range should have shifted from [2,4) to [4,6) */
+	f = editor_get_format_at(ed, 0);
+	ASSERT(f.bold == false); /* byte 0 is now 'X', not bold */
+	f = editor_get_format_at(ed, 4);
+	ASSERT(f.bold == true);  /* byte 4 is now 'a', bold */
+
+	editor_destroy(ed);
+}
+
+/* ---- main --------------------------------------------------------------- */
+
+int main(void) {
+	SDL_Init(0);
+
+	suite_create_destroy();
+	suite_insert_text();
+	suite_delete_back();
+	suite_delete_forward();
+	suite_delete_multibyte();
+	suite_cursor_movement();
+	suite_select_all();
+	suite_replace_body();
+	suite_undo_redo();
+	suite_insert_replace_selection();
+	suite_formatting_range();
+	suite_replace_range();
+	suite_parse_ansi_codes();
+	suite_byte_to_visual_pos();
+	suite_visual_pos_to_byte();
+	suite_tab_expansion();
+	suite_select_word();
+	suite_range_shifts_on_insert();
+
+	SDL_Quit();
+	REPORT_AND_EXIT();
+}
diff --git a/tests/test_harness.h b/tests/test_harness.h
@@ -0,0 +1,68 @@
+#ifndef TEST_HARNESS_H
+#define TEST_HARNESS_H
+#include <stdio.h>
+#include <string.h>
+
+static int tests_run    = 0;
+static int tests_failed = 0;
+
+#define ASSERT(cond) do { \
+	tests_run++; \
+	if (!(cond)) { \
+		fprintf(stderr, "  FAIL  %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+		tests_failed++; \
+	} \
+} while (0)
+
+#define ASSERT_EQ_INT(a, b) do { \
+	tests_run++; \
+	int _a = (a); int _b = (b); \
+	if (_a != _b) { \
+		fprintf(stderr, "  FAIL  %s:%d: %s==%s (%d!=%d)\n", \
+		        __FILE__, __LINE__, #a, #b, _a, _b); \
+		tests_failed++; \
+	} \
+} while (0)
+
+#define ASSERT_EQ_STR(a, b) do { \
+	tests_run++; \
+	const char *_a = (a); const char *_b = (b); \
+	if (!_a || !_b || strcmp(_a, _b) != 0) { \
+		fprintf(stderr, "  FAIL  %s:%d: %s==%s (\"%s\"!=\"%s\")\n", \
+		        __FILE__, __LINE__, #a, #b, \
+		        _a ? _a : "(null)", _b ? _b : "(null)"); \
+		tests_failed++; \
+	} \
+} while (0)
+
+#define ASSERT_NULL(p) do { \
+	tests_run++; \
+	if ((p) != NULL) { \
+		fprintf(stderr, "  FAIL  %s:%d: expected NULL: %s\n", \
+		        __FILE__, __LINE__, #p); \
+		tests_failed++; \
+	} \
+} while (0)
+
+#define ASSERT_NOT_NULL(p) do { \
+	tests_run++; \
+	if ((p) == NULL) { \
+		fprintf(stderr, "  FAIL  %s:%d: expected non-NULL: %s\n", \
+		        __FILE__, __LINE__, #p); \
+		tests_failed++; \
+	} \
+} while (0)
+
+#define RUN_SUITE(name) fprintf(stderr, "suite: %s\n", #name)
+
+#define REPORT_AND_EXIT() do { \
+	if (tests_failed == 0) { \
+		fprintf(stderr, "OK  %d/%d tests passed\n", tests_run, tests_run); \
+		return 0; \
+	} else { \
+		fprintf(stderr, "FAILED  %d/%d tests failed\n", tests_failed, tests_run); \
+		return 1; \
+	} \
+} while (0)
+
+#endif
diff --git a/tests/test_strbuf b/tests/test_strbuf
Binary files differ.
diff --git a/tests/test_strbuf.c b/tests/test_strbuf.c
@@ -0,0 +1,151 @@
+#include "../strbuf.h"
+#include "test_harness.h"
+#include <stdlib.h>
+#include <string.h>
+
+/* ---- suite_init_free ---------------------------------------------------- */
+
+static void suite_init_free(void) {
+	RUN_SUITE(suite_init_free);
+
+	/* default cap (0 -> 1024) */
+	StrBuf sb;
+	strbuf_init(&sb, 0);
+	ASSERT_NOT_NULL(sb.data);
+	ASSERT_EQ_INT((int)sb.len, 0);
+	ASSERT(sb.cap >= 1024);
+	strbuf_free(&sb);
+	ASSERT_NULL(sb.data);
+	ASSERT_EQ_INT((int)sb.len, 0);
+
+	/* explicit small cap */
+	strbuf_init(&sb, 4);
+	ASSERT_NOT_NULL(sb.data);
+	ASSERT_EQ_INT((int)sb.cap, 4);
+	strbuf_free(&sb);
+	ASSERT_NULL(sb.data);
+}
+
+/* ---- suite_insert -------------------------------------------------------- */
+
+static void suite_insert(void) {
+	RUN_SUITE(suite_insert);
+
+	StrBuf sb;
+	strbuf_init(&sb, 64);
+
+	/* insert at position 0 into empty buffer */
+	ASSERT(strbuf_insert(&sb, 0, "hello", 5));
+	ASSERT_EQ_INT((int)sb.len, 5);
+	ASSERT_EQ_STR(sb.data, "hello");
+
+	/* append at end */
+	ASSERT(strbuf_insert(&sb, 5, " world", 6));
+	ASSERT_EQ_INT((int)sb.len, 11);
+	ASSERT_EQ_STR(sb.data, "hello world");
+
+	/* insert in middle */
+	ASSERT(strbuf_insert(&sb, 5, ",", 1));
+	ASSERT_EQ_INT((int)sb.len, 12);
+	ASSERT_EQ_STR(sb.data, "hello, world");
+
+	/* pos > len clamps to end */
+	ASSERT(strbuf_insert(&sb, 9999, "!", 1));
+	ASSERT_EQ_INT((int)sb.len, 13);
+	ASSERT_EQ_STR(sb.data, "hello, world!");
+
+	/* empty insert (text_len == 0) is a no-op */
+	ASSERT(strbuf_insert(&sb, 0, "", 0));
+	ASSERT_EQ_INT((int)sb.len, 13);
+
+	strbuf_free(&sb);
+}
+
+/* ---- suite_delete -------------------------------------------------------- */
+
+static void suite_delete(void) {
+	RUN_SUITE(suite_delete);
+
+	StrBuf sb;
+	strbuf_init(&sb, 64);
+	strbuf_insert(&sb, 0, "hello, world!", 13);
+
+	/* delete from middle */
+	strbuf_delete(&sb, 5, 7); /* remove ", world" */
+	ASSERT_EQ_INT((int)sb.len, 6);
+	ASSERT_EQ_STR(sb.data, "hello!");
+
+	/* pos >= len is a no-op */
+	strbuf_delete(&sb, 6, 1);
+	ASSERT_EQ_INT((int)sb.len, 6);
+
+	/* delete past end clamps */
+	strbuf_delete(&sb, 3, 100);
+	ASSERT_EQ_INT((int)sb.len, 3);
+	ASSERT_EQ_STR(sb.data, "hel");
+
+	/* delete at pos 0 */
+	strbuf_delete(&sb, 0, 2);
+	ASSERT_EQ_INT((int)sb.len, 1);
+	ASSERT_EQ_STR(sb.data, "l");
+
+	strbuf_free(&sb);
+}
+
+/* ---- suite_multibyte ----------------------------------------------------- */
+
+static void suite_multibyte(void) {
+	RUN_SUITE(suite_multibyte);
+
+	/* "café" in UTF-8: c=1, a=1, f=1, é=2 bytes -> 5 bytes total */
+	StrBuf sb;
+	strbuf_init(&sb, 64);
+	strbuf_insert(&sb, 0, "caf\xc3\xa9", 5);
+	ASSERT_EQ_INT((int)sb.len, 5);
+	/* Verify individual bytes are preserved */
+	ASSERT((unsigned char)sb.data[0] == 'c');
+	ASSERT((unsigned char)sb.data[1] == 'a');
+	ASSERT((unsigned char)sb.data[2] == 'f');
+	ASSERT((unsigned char)sb.data[3] == 0xc3);
+	ASSERT((unsigned char)sb.data[4] == 0xa9);
+
+	strbuf_free(&sb);
+}
+
+/* ---- suite_capacity_growth ----------------------------------------------- */
+
+static void suite_capacity_growth(void) {
+	RUN_SUITE(suite_capacity_growth);
+
+	StrBuf sb;
+	strbuf_init(&sb, 4);
+	ASSERT_EQ_INT((int)sb.cap, 4);
+
+	/* Insert 200 bytes worth of 'x' characters one chunk at a time */
+	char chunk[20];
+	memset(chunk, 'x', sizeof(chunk));
+	for (int i = 0; i < 10; i++) {
+		ASSERT(strbuf_insert(&sb, sb.len, chunk, sizeof(chunk)));
+	}
+
+	ASSERT_EQ_INT((int)sb.len, 200);
+	ASSERT(sb.cap >= 200);
+
+	/* Verify all data is intact */
+	for (int i = 0; i < 200; i++) {
+		ASSERT(sb.data[i] == 'x');
+	}
+
+	strbuf_free(&sb);
+}
+
+/* ---- main ---------------------------------------------------------------- */
+
+int main(void) {
+	suite_init_free();
+	suite_insert();
+	suite_delete();
+	suite_multibyte();
+	suite_capacity_growth();
+	REPORT_AND_EXIT();
+}