summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-12 21:13:45 +0300
committerPaul Buetow <paul@buetow.org>2026-07-12 21:13:45 +0300
commit89727590e718d3628a0847689182e896df646b8a (patch)
tree3106397d1b041d95ecfc04762548261f9d660d62 /tests
parent198b0e8112905bc003f954fc9159cc3da2dfce09 (diff)
walkdir: navigator, shortcuts, window wiring, drop target, tests (ht0)
M2 (task ht0): walk the directory. - src/navigator.{c,h}: GObject (no GtkWidget) holding the folder listing โ€” image-MIME filter + extension fallback, hide-RAW-sidecars (decision #33), sort name/time/size, cursor + prev/next/wrap, path-based marks (survive re-sort, pruned on rescan/monitor, cleared on remove), nearest-fallback, GFileMonitor debounced 250ms (decision #28), 'changed' signal. 92% gcov. - src/shortcuts.{c,h}: GtkShortcutController (MANAGED) binding h/l/Left/Right/ g/G/o/q to win.* actions; one table all milestones add to. - src/window.{c,h}: open file->parent+current / folder->first; h/l/g/G actions -> nav -> load -> viewer; single GCancellable last-write-wins (g_file_equal guard for M3 async); header title 'filename ยท n/total'; GtkDropTarget (GDK_TYPE_FILE_LIST, many->first, decision Z); GtkFileDialog open. - data/gschema: add hide-raw-sidecars key. - tests: unit test_navigator (12 subtests), integration test_walk_folder (temp fixtures walk + ./sample-images skip-if-absent). 8/8 green; ASan clean (GIO_USE_VFS=local in fixtures_env). Fixed during this milestone: a UAF (borrowed c_name used after unref), a cancellable double-ref leak, time::modified UINT64 vs INT64, and several test-harness leaks. Sub-agent review issues addressed (mark pruning on rescan/monitor, remove-before-cursor test, recursive temp-dir cleanup, callback _cb naming, connect-before-mutate).
Diffstat (limited to 'tests')
-rw-r--r--tests/meson.build24
-rw-r--r--tests/test_navigator.c483
-rw-r--r--tests/test_walk_folder.c196
-rw-r--r--tests/test_window.c18
4 files changed, 714 insertions, 7 deletions
diff --git a/tests/meson.build b/tests/meson.build
index 76d4962..bd45863 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -14,8 +14,10 @@
# GGAZE_FIXTURES_DIR committed tests/fixtures/ (CI-portable baseline)
# GGAZE_SAMPLE_DIR local ./sample-images (not git-tracked; tests skip
# cleanly when absent so CI stays green)
+# GIO_USE_VFS=local avoid gvfs daemon-VFS internal leaks under ASan
fixtures_env = environment()
+fixtures_env.set('GIO_USE_VFS', 'local')
fixtures_env.set('GGAZE_FIXTURES_DIR',
join_paths(meson.project_source_root(), 'tests', 'fixtures'))
fixtures_env.set('GGAZE_SAMPLE_DIR',
@@ -65,6 +67,16 @@ test_loader_pixbuf = executable(
)
test('loader_pixbuf', test_loader_pixbuf, suite : 'unit', env : fixtures_env)
+test_navigator = executable(
+ 'test_navigator',
+ ['test_navigator.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : [glib_dep, gio_dep],
+ link_with : ggaze_lib,
+ install : false,
+)
+test('navigator', test_navigator, suite : 'unit', env : fixtures_env)
+
# --- integration track (needs a display; CI uses xvfb-run) ---
test_window = executable(
'test_window',
@@ -74,7 +86,7 @@ test_window = executable(
link_with : ggaze_lib,
install : false,
)
-test('window', test_window, suite : 'integration')
+test('window', test_window, suite : 'integration', env : fixtures_env)
test_open_and_show = executable(
'test_open_and_show',
@@ -88,4 +100,14 @@ test('open_and_show', test_open_and_show,
suite : 'integration',
env : fixtures_env)
+test_walk_folder = executable(
+ 'test_walk_folder',
+ ['test_walk_folder.c', ggaze_conf_h],
+ include_directories : [inc, src_inc],
+ dependencies : ggaze_deps,
+ link_with : ggaze_lib,
+ install : false,
+)
+test('walk_folder', test_walk_folder, suite : 'integration', env : fixtures_env)
+
subdir('integration') \ No newline at end of file
diff --git a/tests/test_navigator.c b/tests/test_navigator.c
new file mode 100644
index 0000000..e8528b1
--- /dev/null
+++ b/tests/test_navigator.c
@@ -0,0 +1,483 @@
+/*:*
+ * ggaze โ€” navigator unit test
+ *
+ * Exercises the Navigator over temp directories (no display, no GTK widgets):
+ * listing + image filter, RAW-sidecar hiding, sort (name/time/size), cursor +
+ * prev/next/wrap, marks (toggle/range/all/clear/survive-resort), rescan +
+ * nearest-fallback, navigator_remove, the GFileMonitor debounce path, the
+ * "changed" signal, and empty-dir accessors.
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "navigator.h"
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <stdlib.h>
+
+/* --- helpers ------------------------------------------------------------- */
+/* write_file/touch_file create a file on disk and return nothing (no GFile to
+ * leak). file_ref() returns a fresh GFile handle for tests that need to keep a
+ * reference (to pass to the navigator, delete, or set attributes). */
+
+static char *
+make_temp_dir(void) {
+ GError *p_err = NULL;
+ char *c_dir = g_dir_make_tmp("ggaze-nav-XXXXXX", &p_err);
+ g_assert_no_error(p_err);
+ return (c_dir);
+}
+
+/* Recursively remove a flat temp dir tree, then free the path string. */
+static void
+cleanup_temp_dir(char *c_dir) {
+ GFile *p_dir = g_file_new_for_path(c_dir);
+ GFileEnumerator *p_e =
+ g_file_enumerate_children(p_dir, "standard::name,standard::type",
+ G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (p_e != NULL) {
+ GFileInfo *p_info;
+ while ((p_info = g_file_enumerator_next_file(p_e, NULL, NULL)) != NULL) {
+ GFile *p_child = g_file_get_child(p_dir, g_file_info_get_name(p_info));
+ g_file_delete(p_child, NULL, NULL);
+ g_object_unref(p_child);
+ g_object_unref(p_info);
+ }
+ g_object_unref(p_e);
+ }
+ g_file_delete(p_dir, NULL, NULL);
+ g_object_unref(p_dir);
+ g_free(c_dir);
+}
+
+static void
+write_file(const char *c_dir, const char *c_name, const char *c_body,
+ gssize i_len) {
+ char *c_path = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ GError *p_err = NULL;
+ GOutputStream *p_out = (GOutputStream *)g_file_replace(
+ p_file, NULL, FALSE, G_FILE_CREATE_REPLACE_DESTINATION, NULL, &p_err);
+ g_assert_no_error(p_err);
+ if (i_len < 0) {
+ i_len = (gssize)strlen(c_body);
+ }
+ gsize u_written = 0;
+ g_assert_true(g_output_stream_write_all(p_out, c_body, (gsize)i_len,
+ &u_written, NULL, &p_err));
+ g_assert_no_error(p_err);
+ g_assert_true(g_output_stream_close(p_out, NULL, &p_err));
+ g_assert_no_error(p_err);
+ g_object_unref(p_out);
+ g_object_unref(p_file);
+ g_free(c_path);
+}
+
+static void
+touch_file(const char *c_dir, const char *c_name) {
+ write_file(c_dir, c_name, "x", 1);
+}
+
+static GFile *
+file_ref(const char *c_dir, const char *c_name) {
+ char *c_path = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ g_free(c_path);
+ return (p_file); /* (transfer full) */
+}
+
+/* Get the basename, assert it equals c_expected, and free it (LSan-clean). */
+static void
+assert_name(GFile *p_file, const char *c_expected) {
+ char *c_name = g_file_get_basename(p_file);
+ g_assert_cmpstr(c_name, ==, c_expected);
+ g_free(c_name);
+}
+
+/* pump the default main context for up to u_ms, returning TRUE if p_pred
+ * became TRUE. */
+static gboolean
+pump_until(gboolean (*p_pred)(gpointer), gpointer p_data, guint u_ms) {
+ GMainContext *p_ctx = g_main_context_default();
+ for (guint u = 0; u < u_ms; u++) {
+ if (p_pred != NULL && p_pred(p_data)) {
+ return (TRUE);
+ }
+ g_main_context_iteration(p_ctx, FALSE);
+ g_usleep(1000); /* 1 ms */
+ }
+ return (p_pred != NULL && p_pred(p_data));
+}
+
+static gboolean
+_count_is(gpointer p_data) {
+ return (navigator_get_count((Navigator *)p_data) ==
+ GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(p_data), "want")));
+}
+
+/* --- tests --------------------------------------------------------------- */
+
+static void
+test_filter_and_listing(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "xx", 2);
+ write_file(c_dir, "b.png", "yyy", 3);
+ write_file(c_dir, "c.txt", "z", 1); /* excluded: not an image */
+ write_file(c_dir, ".hidden.jpg", "h", 1); /* excluded: dotfile */
+ write_file(c_dir, "d.gif", "yy", 2);
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 3); /* a, b, d */
+ assert_name(navigator_get_file(p_nav, 0), "a.jpg");
+ assert_name(navigator_get_file(p_nav, 1), "b.png");
+ assert_name(navigator_get_file(p_nav, 2), "d.gif");
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_hide_raw_sidecars(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "img.jpg", "x", 1);
+ write_file(c_dir, "img.raf", "x", 1); /* RAW twin of img.jpg */
+ write_file(c_dir, "lonely.nef", "x", 1); /* no JPEG twin -> kept */
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 2); /* img.jpg, lonely.nef */
+ g_assert_true(navigator_get_hide_raw(p_nav));
+
+ navigator_set_hide_raw(p_nav, FALSE);
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 3); /* all three */
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_sort_time_and_size(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "12345", 5);
+ write_file(c_dir, "b.jpg", "0123456789ABCDEF", 16);
+ GFile *p_small = file_ref(c_dir, "a.jpg");
+ GFile *p_big = file_ref(c_dir, "b.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ g_assert_cmpint(navigator_get_sort(p_nav), ==, GGAZE_SORT_NAME);
+ assert_name(navigator_get_current(p_nav), "a.jpg");
+
+ navigator_set_sort(p_nav, GGAZE_SORT_SIZE);
+ assert_name(navigator_get_file(p_nav, 0), "a.jpg"); /* size 5 < 16 */
+
+ /* Give a.jpg a newer mtime than b.jpg via a direct attribute set. */
+ guint64 u_t_old = (guint64)1000000;
+ guint64 u_t_new = (guint64)9000000;
+ GError *p_seterr = NULL;
+ g_assert_true(g_file_set_attribute(p_big, "time::modified",
+ G_FILE_ATTRIBUTE_TYPE_UINT64, &u_t_old,
+ G_FILE_QUERY_INFO_NONE, NULL, &p_seterr));
+ g_assert_no_error(p_seterr);
+ g_assert_true(g_file_set_attribute(p_small, "time::modified",
+ G_FILE_ATTRIBUTE_TYPE_UINT64, &u_t_new,
+ G_FILE_QUERY_INFO_NONE, NULL, &p_seterr));
+ g_assert_no_error(p_seterr);
+
+ navigator_set_sort(p_nav, GGAZE_SORT_TIME);
+ /* ascending: b.jpg (older) first, a.jpg (newer) last. */
+ assert_name(navigator_get_file(p_nav, 0), "b.jpg");
+ assert_name(navigator_get_file(p_nav, navigator_get_count(p_nav) - 1),
+ "a.jpg");
+
+ navigator_delete(p_nav);
+ g_object_unref(p_small);
+ g_object_unref(p_big);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_prev_next_wrap(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0);
+
+ g_assert_true(navigator_next(p_nav));
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1);
+ g_assert_true(navigator_next(p_nav));
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2);
+ g_assert_true(navigator_next(p_nav)); /* wrap to 0 */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0);
+ g_assert_true(navigator_prev(p_nav)); /* wrap to last */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2);
+
+ navigator_set_wrap(p_nav, FALSE);
+ g_assert_cmpint(navigator_get_wrap(p_nav), ==, FALSE);
+ navigator_last(p_nav);
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2);
+ g_assert_false(navigator_next(p_nav)); /* no wrap */
+ navigator_first(p_nav);
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0);
+ g_assert_false(navigator_prev(p_nav)); /* no wrap */
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_set_current_file(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 0); /* a.jpg */
+
+ g_assert_true(navigator_set_current_file(p_nav, p_b)); /* b.jpg at idx 1 */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1);
+ assert_name(navigator_get_current(p_nav), "b.jpg");
+
+ navigator_delete(p_nav);
+ g_object_unref(p_b);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_marks(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_a = file_ref(c_dir, "a.jpg");
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+ GFile *p_c = file_ref(c_dir, "c.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+ navigator_toggle_mark(p_nav, p_b);
+ g_assert_true(navigator_is_marked(p_nav, p_b));
+ g_assert_false(navigator_is_marked(p_nav, p_a));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 1);
+ navigator_toggle_mark(p_nav, p_b); /* untoggle */
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+
+ navigator_mark_range(p_nav, p_a, p_c); /* mark a, b, c */
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3);
+
+ /* marks survive a re-sort */
+ navigator_set_sort(p_nav, GGAZE_SORT_SIZE);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3);
+ g_assert_true(navigator_is_marked(p_nav, p_a));
+
+ GList *p_marks = navigator_get_marks(p_nav);
+ g_assert_cmpint(g_list_length(p_marks), ==, 3);
+ g_list_free_full(p_marks, (GDestroyNotify)g_object_unref);
+
+ navigator_clear_marks(p_nav);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+
+ navigator_mark_all(p_nav);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3);
+
+ navigator_delete(p_nav);
+ g_object_unref(p_a);
+ g_object_unref(p_b);
+ g_object_unref(p_c);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_rescan_and_nearest_fallback(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ navigator_set_current_file(p_nav, p_b); /* current = b.jpg (idx 1) */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1);
+
+ /* Delete b.jpg from disk and rescan: current gone -> nearest fallback. */
+ g_assert_true(g_file_delete(p_b, NULL, NULL));
+ navigator_rescan(p_nav);
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 2);
+ /* nearest by position: old idx 1 -> stays at idx 1 (now c.jpg). */
+ assert_name(navigator_get_current(p_nav), "c.jpg");
+
+ /* Adding a file via rescan keeps the current. */
+ touch_file(c_dir, "d.jpg");
+ navigator_rescan(p_nav);
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 3);
+ assert_name(navigator_get_current(p_nav), "c.jpg");
+
+ navigator_delete(p_nav);
+ g_object_unref(p_b);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_remove_clears_mark_and_falls_back(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_b = file_ref(c_dir, "b.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ navigator_set_current_file(p_nav, p_b);
+ navigator_mark_all(p_nav);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 3);
+
+ g_assert_true(navigator_remove(p_nav, p_b)); /* remove current */
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 2);
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==,
+ 2); /* b's mark cleared */
+ /* nearest fallback: was idx 1 -> now c.jpg at idx 1. */
+ assert_name(navigator_get_current(p_nav), "c.jpg");
+
+ g_assert_false(navigator_remove(p_nav, p_b)); /* already gone */
+
+ navigator_delete(p_nav);
+ g_object_unref(p_b);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_monitor_add(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ navigator_set_debounce_ms(p_nav, 5); /* fast for the test */
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 1);
+
+ touch_file(c_dir, "b.jpg");
+ g_object_set_data(G_OBJECT(p_nav), "want", GUINT_TO_POINTER(2));
+ g_assert_true(pump_until(_count_is, p_nav, 2000));
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+on_changed_cb(Navigator *p_nav, gpointer p_data) {
+ (void)p_nav;
+ (*(gint *)p_data)++;
+}
+
+static void
+test_changed_signal(void) {
+ char *c_dir = make_temp_dir();
+ touch_file(c_dir, "a.jpg");
+ touch_file(c_dir, "b.jpg");
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ gint i_count = 0;
+ g_signal_connect(p_nav, "changed", G_CALLBACK(on_changed_cb), &i_count);
+
+ navigator_emit_changed(p_nav);
+ g_assert_cmpint(i_count, ==, 1);
+ navigator_next(p_nav);
+ g_assert_cmpint(i_count, ==, 2);
+ navigator_set_sort(p_nav, GGAZE_SORT_SIZE);
+ g_assert_cmpint(i_count, ==, 3);
+ navigator_clear_marks(p_nav);
+ g_assert_cmpint(i_count, ==, 4);
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_empty_dir(void) {
+ char *c_dir = make_temp_dir();
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+
+ g_assert_cmpint(navigator_get_count(p_nav), ==, 0);
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, -1);
+ g_assert_null(navigator_get_current(p_nav));
+ g_assert_false(navigator_prev(p_nav));
+ g_assert_false(navigator_next(p_nav));
+ g_assert_false(navigator_first(p_nav));
+ g_assert_false(navigator_last(p_nav));
+ g_assert_cmpint(navigator_get_mark_count(p_nav), ==, 0);
+ g_assert_null(navigator_get_marks(p_nav));
+ g_assert_cmpint(navigator_get_remaining(p_nav), ==, 0);
+ g_assert_true(g_file_equal(navigator_get_dir(p_nav), p_dirf));
+
+ navigator_delete(p_nav);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_remove_before_cursor(void) {
+ char *c_dir = make_temp_dir();
+ write_file(c_dir, "a.jpg", "x", 1);
+ write_file(c_dir, "b.jpg", "x", 1);
+ write_file(c_dir, "c.jpg", "x", 1);
+ GFile *p_a = file_ref(c_dir, "a.jpg");
+ GFile *p_c = file_ref(c_dir, "c.jpg");
+
+ GFile *p_dirf = g_file_new_for_path(c_dir);
+ Navigator *p_nav = navigator_new(p_dirf, GGAZE_SORT_NAME, TRUE, TRUE);
+ navigator_set_current_file(p_nav, p_c); /* current = c.jpg (idx 2) */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 2);
+ g_assert_true(navigator_remove(p_nav, p_a)); /* remove idx 0 */
+ /* cursor decrements: c.jpg stays current, now at idx 1. */
+ g_assert_cmpint(navigator_get_current_index(p_nav), ==, 1);
+ assert_name(navigator_get_current(p_nav), "c.jpg");
+
+ navigator_delete(p_nav);
+ g_object_unref(p_a);
+ g_object_unref(p_c);
+ g_object_unref(p_dirf);
+ cleanup_temp_dir(c_dir);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ g_test_add_func("/navigator/filter_and_listing", test_filter_and_listing);
+ g_test_add_func("/navigator/hide_raw_sidecars", test_hide_raw_sidecars);
+ g_test_add_func("/navigator/sort_time_and_size", test_sort_time_and_size);
+ g_test_add_func("/navigator/prev_next_wrap", test_prev_next_wrap);
+ g_test_add_func("/navigator/set_current_file", test_set_current_file);
+ g_test_add_func("/navigator/marks", test_marks);
+ g_test_add_func("/navigator/rescan_fallback",
+ test_rescan_and_nearest_fallback);
+ g_test_add_func("/navigator/remove", test_remove_clears_mark_and_falls_back);
+ g_test_add_func("/navigator/remove_before_cursor",
+ test_remove_before_cursor);
+ g_test_add_func("/navigator/monitor_add", test_monitor_add);
+ g_test_add_func("/navigator/changed_signal", test_changed_signal);
+ g_test_add_func("/navigator/empty_dir", test_empty_dir);
+ return (g_test_run());
+} \ No newline at end of file
diff --git a/tests/test_walk_folder.c b/tests/test_walk_folder.c
new file mode 100644
index 0000000..660b164
--- /dev/null
+++ b/tests/test_walk_folder.c
@@ -0,0 +1,196 @@
+/*:*
+ * ggaze โ€” walk-the-folder integration test
+ *
+ * Drives the real window wiring: open a file (its parent folder becomes the
+ * navigator), then walk prev/next/first/last and assert the viewer's texture
+ * dimensions update and the header carries "n/total". Uses committed fixtures
+ * copied into a temp dir; an optional ./sample-images variant skips when the
+ * corpus is absent. Needs a display (integration suite; CI uses xvfb).
+ *
+ * Copyright (c) 2026 ggaze contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *:*/
+
+#include "viewer.h"
+#include "window.h"
+
+#include <gdk/gdk.h>
+#include <gio/gio.h>
+#include <glib.h>
+#include <gtk/gtk.h>
+#include <string.h>
+
+static void
+copy_fixture(const char *c_dir, const char *c_name) {
+ const gchar *c_fx = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_fx);
+ char *c_src = g_build_filename(c_fx, c_name, NULL);
+ char *c_dst = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_src = g_file_new_for_path(c_src);
+ GFile *p_dst = g_file_new_for_path(c_dst);
+ GError *p_err = NULL;
+ g_assert_true(g_file_copy(p_src, p_dst, G_FILE_COPY_OVERWRITE, NULL, NULL,
+ NULL, &p_err));
+ g_assert_no_error(p_err);
+ g_object_unref(p_src);
+ g_object_unref(p_dst);
+ g_free(c_src);
+ g_free(c_dst);
+}
+
+/* A fresh GFile handle for a file created by copy_fixture (transfer full). */
+static GFile *
+file_in(const char *c_dir, const char *c_name) {
+ char *c_path = g_build_filename(c_dir, c_name, NULL);
+ GFile *p_file = g_file_new_for_path(c_path);
+ g_free(c_path);
+ return (p_file);
+}
+
+/* Recursively remove a flat temp dir tree, then free the path string. */
+static void
+cleanup_temp_dir(char *c_dir) {
+ GFile *p_dir = g_file_new_for_path(c_dir);
+ GFileEnumerator *p_e =
+ g_file_enumerate_children(p_dir, "standard::name,standard::type",
+ G_FILE_QUERY_INFO_NONE, NULL, NULL);
+ if (p_e != NULL) {
+ GFileInfo *p_info;
+ while ((p_info = g_file_enumerator_next_file(p_e, NULL, NULL)) != NULL) {
+ GFile *p_child = g_file_get_child(p_dir, g_file_info_get_name(p_info));
+ g_file_delete(p_child, NULL, NULL);
+ g_object_unref(p_child);
+ g_object_unref(p_info);
+ }
+ g_object_unref(p_e);
+ }
+ g_file_delete(p_dir, NULL, NULL);
+ g_object_unref(p_dir);
+ g_free(c_dir);
+}
+
+static GgazeWindow *
+new_window(void) {
+ return (GGAZE_WINDOW(g_object_new(GGAZE_TYPE_WINDOW, NULL)));
+}
+
+static GdkTexture *
+viewer_texture(GgazeWindow *p_win) {
+ GtkWidget *p_child = gtk_window_get_child(GTK_WINDOW(p_win));
+ GtkWidget *p_large =
+ gtk_stack_get_child_by_name(GTK_STACK(p_child), "large");
+ return (ggaze_viewer_get_texture(GGAZE_VIEWER(p_large)));
+}
+
+static void
+assert_dims(GgazeWindow *p_win, int i_w, int i_h) {
+ GdkTexture *p_tex = viewer_texture(p_win);
+ g_assert_nonnull(p_tex);
+ g_assert_cmpint(gdk_texture_get_width(p_tex), ==, i_w);
+ g_assert_cmpint(gdk_texture_get_height(p_tex), ==, i_h);
+}
+
+static void
+assert_title_has(GgazeWindow *p_win, const char *c_substr) {
+ const gchar *c_title = gtk_window_get_title(GTK_WINDOW(p_win));
+ g_assert_nonnull(c_title);
+ g_assert_nonnull(g_strstr_len(c_title, -1, c_substr));
+}
+
+/* Name sort: plain.jpg (6x3), rot6.jpg (8x4 orient6 -> 4x8), small.png (5x2).
+ */
+static void
+test_walk_temp(void) {
+ GError *p_err = NULL;
+ char *c_dir = g_dir_make_tmp("ggaze-walk-XXXXXX", &p_err);
+ g_assert_no_error(p_err);
+
+ copy_fixture(c_dir, "plain.jpg");
+ copy_fixture(c_dir, "rot6.jpg");
+ copy_fixture(c_dir, "small.png");
+ GFile *p_plain = file_in(c_dir, "plain.jpg");
+
+ GgazeWindow *p_win = new_window();
+ ggaze_window_open(p_win, p_plain); /* current = plain.jpg */
+ assert_dims(p_win, 6, 3);
+ assert_title_has(p_win, "1/3");
+
+ ggaze_window_next(p_win);
+ assert_dims(p_win, 4, 8); /* rot6.jpg */
+ assert_title_has(p_win, "2/3");
+
+ ggaze_window_next(p_win);
+ assert_dims(p_win, 5, 2); /* small.png */
+ assert_title_has(p_win, "3/3");
+
+ ggaze_window_prev(p_win);
+ assert_dims(p_win, 4, 8); /* back to rot6.jpg */
+
+ ggaze_window_first(p_win);
+ assert_dims(p_win, 6, 3);
+ ggaze_window_last(p_win);
+ assert_dims(p_win, 5, 2);
+
+ g_object_unref(p_win);
+ g_object_unref(p_plain);
+ cleanup_temp_dir(c_dir);
+}
+
+static void
+test_walk_sample(void) {
+ const gchar *c_dir = g_getenv("GGAZE_SAMPLE_DIR");
+ if (c_dir == NULL || *c_dir == '\0' ||
+ !g_file_test(c_dir, G_FILE_TEST_IS_DIR)) {
+ g_test_skip("GGAZE_SAMPLE_DIR unset/absent (./sample-images)");
+ return;
+ }
+ /* Pick the first JPEG. */
+ GError *p_err = NULL;
+ GDir *p_d = g_dir_open(c_dir, 0, &p_err);
+ g_assert_no_error(p_err);
+ const gchar *c_name = NULL;
+ while ((c_name = g_dir_read_name(p_d)) != NULL) {
+ if (g_str_has_suffix(c_name, ".jpg") ||
+ g_str_has_suffix(c_name, ".JPG") ||
+ g_str_has_suffix(c_name, ".png")) {
+ break;
+ }
+ }
+ /* c_name is borrowed from p_d; build the path before closing the dir. */
+ char *c_path =
+ (c_name != NULL) ? g_build_filename(c_dir, c_name, NULL) : NULL;
+ g_dir_close(p_d);
+ if (c_path == NULL) {
+ g_test_skip("no sample image found in corpus");
+ return;
+ }
+ GgazeWindow *p_win = new_window();
+ GFile *p_file = g_file_new_for_path(c_path);
+ ggaze_window_open(p_win, p_file);
+ g_object_unref(p_file);
+ g_assert_nonnull(viewer_texture(p_win));
+
+ /* Walk a few; each step must produce a (possibly new) texture. */
+ for (int i = 0; i < 5; i++) {
+ ggaze_window_next(p_win);
+ g_assert_nonnull(viewer_texture(p_win));
+ }
+
+ g_object_unref(p_win);
+ g_free(c_path);
+}
+
+int
+main(int i_argc, char **c_argv) {
+ g_test_init(&i_argc, &c_argv, NULL);
+ /* Tolerate host GTK WARNINGs; keep CRITICALs fatal. */
+ g_log_set_always_fatal(G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL);
+
+ if (!gtk_init_check()) {
+ g_test_skip("no display available (run under xvfb)");
+ return (g_test_run());
+ }
+ g_test_add_func("/walk/temp", test_walk_temp);
+ g_test_add_func("/walk/sample", test_walk_sample);
+ return (g_test_run());
+} \ No newline at end of file
diff --git a/tests/test_window.c b/tests/test_window.c
index 0f3fa74..63e54bb 100644
--- a/tests/test_window.c
+++ b/tests/test_window.c
@@ -50,15 +50,21 @@ test_stack_has_two_views(void) {
static void
test_open_titles_window(void) {
- GgazeWindow *p_win = new_window();
-
- GFile *p_file = g_file_new_for_path("/tmp/IMG_0001.jpg");
+ const gchar *c_dir = g_getenv("GGAZE_FIXTURES_DIR");
+ g_assert_nonnull(c_dir);
+ gchar *c_path = g_build_filename(c_dir, "plain.jpg", NULL);
+ GgazeWindow *p_win = new_window();
+ GFile *p_file = g_file_new_for_path(c_path);
ggaze_window_open(p_win, p_file);
-
- g_assert_cmpstr(gtk_window_get_title(GTK_WINDOW(p_win)), ==, "IMG_0001.jpg");
-
+ /* M2: opening a file lists its parent folder; the header title carries
+ * the current filename + "n/total". */
+ const gchar *c_title = gtk_window_get_title(GTK_WINDOW(p_win));
+ g_assert_nonnull(c_title);
+ g_assert_nonnull(g_strstr_len(c_title, -1, "plain.jpg"));
+ g_assert_nonnull(g_strstr_len(c_title, -1, "/"));
g_object_unref(p_file);
g_object_unref(p_win);
+ g_free(c_path);
}
int