Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
test_image_cache_flags_writeback.c
Go to the documentation of this file.
1/*
2 This file is part of Ansel,
3 Copyright (C) 2026 Aurélien PIERRE.
4
5 Ansel is free software: you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 Ansel is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with Ansel. If not, see <http://www.gnu.org/licenses/>.
17*/
18
32#include "testdb.h"
33
34#include "caches/image_cache.h"
35#include "common/conf.h"
36#include "common/image.h"
37#include "control/crawler.h"
38#include "darktable.h"
39
40#include <glib/gstdio.h>
41#include <stdlib.h> // calloc/free, used directly below
42
43// two bits standing in for the crawl's own DT_IMAGE_HAS_TXT / DT_IMAGE_HAS_WAV
44#define CRAWL_BIT_A (1 << 4)
45#define CRAWL_BIT_B (1 << 5)
46#define CRAWL_MASK (CRAWL_BIT_A | CRAWL_BIT_B)
47// a bit standing in for what the user owns and the crawl must never touch
48#define USER_BIT 2048
49
50static char *_rcfile = NULL;
51
52static int cache_setup(void **state)
53{
54 const int rc = testdb_setup(state);
55 if(rc) return rc;
56
57 /* Populating a cache entry reads conf: dt_image_repository_load() derives the film-roll
58 * name, and dt_image_film_roll_name() asks for `show_folder_levels'. Same fixture as
59 * test_conf_value_lifetime.c -- dt_conf_init() writes through the global, so the global
60 * must point at the instance being initialised before the call. */
61 _rcfile = g_build_filename(g_get_tmp_dir(), "ansel_test_image_cache_flags.rc", NULL);
62 g_remove(_rcfile);
63 darktable.conf = (dt_conf_t *)calloc(1, sizeof(dt_conf_t));
65
67 return 0;
68}
69
70static int cache_teardown(void **state)
71{
73
75 free(darktable.conf);
76 darktable.conf = NULL;
77 g_remove(_rcfile);
78 g_free(_rcfile);
79 _rcfile = NULL;
80
81 return testdb_teardown(state);
82}
83
85static int32_t _image_with_flags(const char *folder, const char *name, const int flags)
86{
87 const int32_t film = testdb_make_film(folder);
88 assert_true(film > 0);
89 const int32_t img = testdb_make_image(film, name);
90 assert_true(img > 0);
91 /* An image imported through the application leads a group of its own;
92 * dt_image_repository_insert_import() leaves group_id NULL, and the cache's write-through
93 * rewrites the whole row -- which a NULL group_id fails as a constraint. */
94 assert_true(dt_image_repository_set_group(img, img));
95 assert_true(dt_image_repository_set_flags(img, flags));
96 return img;
97}
98
99static gboolean _row_has(const int32_t imgid, const int flag)
100{
101 GList *one = g_list_append(NULL, GINT_TO_POINTER(imgid));
103 const gboolean found = (g_list_length(got) == 1);
104 g_list_free(got);
105 g_list_free(one);
106 return found;
107}
108
109/* The hazard itself: a row written behind a cached entry does not survive that entry's next
110 * release. If this ever stops being true the crawler's care below is no longer needed -- but
111 * until then, nothing may write flags straight to the row for a cached image. */
113{
114 (void)state;
115 const int32_t img = _image_with_flags("/testdb/behind", "behind.raw", USER_BIT);
116
117 // the user looks at the image: it now has a cache entry, holding flags as the row had them
118 dt_image_t *cached = dt_image_cache_get(img, 'r');
119 assert_non_null(cached);
120 assert_int_equal(cached->flags & CRAWL_MASK, 0);
122
123 // something writes the row straight through the repository, cache none the wiser
125 assert_true(_row_has(img, CRAWL_BIT_A));
126
127 // the user rates the image: the entry is released, and writes its whole word back
128 dt_image_t *rated = dt_image_cache_get(img, 'w');
129 assert_non_null(rated);
130 rated->flags |= 1; // one star
132
133 // the row update is gone, and nothing said so
134 assert_false(_row_has(img, CRAWL_BIT_A));
135}
136
137/* The sequence control/crawler.c uses instead: edit the entry when there is one. The rating
138 * that follows then carries the crawl's bits rather than reverting them. */
140{
141 (void)state;
142 const int32_t img = _image_with_flags("/testdb/entry", "entry.raw", USER_BIT);
143
144 dt_image_t *cached = dt_image_cache_get(img, 'r');
145 assert_non_null(cached);
147
148 // the crawl finds an entry, so it edits the entry rather than the row
149 dt_image_t *edited = dt_image_cache_testget(img, 'w');
150 assert_non_null(edited);
151 edited->flags = (edited->flags & ~CRAWL_MASK) | CRAWL_BIT_A;
153 assert_true(_row_has(img, CRAWL_BIT_A));
154
155 // the user rates it afterwards
156 dt_image_t *rated = dt_image_cache_get(img, 'w');
157 assert_non_null(rated);
158 rated->flags |= 1;
160
161 // both survive: the crawl's bit and the bit the user owns
162 assert_true(_row_has(img, CRAWL_BIT_A));
163 assert_true(_row_has(img, USER_BIT));
164}
165
166/* testget() must not create an entry: a crawl over the whole library calls it once per image,
167 * and an allocating answer would pull the whole library into a 50 MiB cache. */
169{
170 (void)state;
171 const int32_t img = _image_with_flags("/testdb/uncached", "uncached.raw", USER_BIT);
172
173 assert_null(dt_image_cache_testget(img, 'w'));
174
175 // so the crawl writes the row, and that write stands
177 assert_true(_row_has(img, CRAWL_BIT_B));
178 assert_true(_row_has(img, USER_BIT));
179}
180
181/* The crawl itself, over a real directory, on an image the user has already looked at.
182 *
183 * This is the sequence the fix exists for: the crawl notices a companion .txt, and the entry
184 * sitting in the cache must come away carrying that -- otherwise the next rating writes the
185 * entry's stale word back over the row. */
187{
188 (void)state;
189 gchar *dir = g_dir_make_tmp("ansel_test_crawl_XXXXXX", NULL);
190 assert_non_null(dir);
191 gchar *raw = g_build_filename(dir, "shot.raw", NULL);
192 gchar *txt = g_build_filename(dir, "shot.txt", NULL);
193 /* Version 0 takes no `_NN' suffix, so the sidecar is `<name>.xmp'. It has to exist at all:
194 * _crawl_image() gives up on an image with no sidecar before it ever looks for companion
195 * files, which is the behaviour the per-file stat() version had too. */
196 gchar *xmp = g_build_filename(dir, "shot.raw.xmp", NULL);
197 assert_true(g_file_set_contents(raw, "", 0, NULL));
198 assert_true(g_file_set_contents(txt, "note", 4, NULL));
199 assert_true(g_file_set_contents(xmp, "<x/>", 4, NULL));
200
201 const int32_t img = _image_with_flags(dir, "shot.raw", USER_BIT);
202 assert_false(_row_has(img, DT_IMAGE_HAS_TXT));
203
204 /* Date the row after the sidecar, so the crawl has nothing to report and the only thing it
205 * does on this image is the companion-flags update this test is about. */
206 assert_true(dt_image_repository_set_write_timestamp(img, (int64_t)g_get_real_time() / 1000000 + 86400));
207
208 // the user has looked at this image, so it has a cache entry -- holding no HAS_TXT
209 dt_image_t *looked = dt_image_cache_get(img, 'r');
210 assert_non_null(looked);
211 assert_int_equal(looked->flags & DT_IMAGE_HAS_TXT, 0);
213
214 // the row is dated after the sidecar, so the crawl reports nothing and only updates the
215 // companion flags
216 GList *changed = dt_control_crawler_run();
217 assert_null(changed);
218
219 assert_true(_row_has(img, DT_IMAGE_HAS_TXT));
220
221 // and the entry knows it too, which is the whole point: rating the image now keeps it
222 dt_image_t *rated = dt_image_cache_get(img, 'w');
223 assert_non_null(rated);
224 assert_int_equal(rated->flags & DT_IMAGE_HAS_TXT, DT_IMAGE_HAS_TXT);
225 rated->flags |= 1; // one star
227
228 assert_true(_row_has(img, DT_IMAGE_HAS_TXT));
229 assert_true(_row_has(img, USER_BIT));
230
231 g_remove(xmp);
232 g_remove(txt);
233 g_remove(raw);
234 g_remove(dir);
235 g_free(xmp);
236 g_free(txt);
237 g_free(raw);
238 g_free(dir);
239}
240
241/* A directory holding `shot.raw', its sidecar and a companion `shot.txt', and an image row
242 * named @p db_name there, dated after the sidecar: a crawl then has nothing to report on it,
243 * and the only thing it does is the companion-flags update these tests are about. */
244typedef struct _crawl_dir_t
245{
246 gchar *dir, *raw, *xmp, *txt;
247 int32_t img;
249
250static _crawl_dir_t _crawl_dir_new(const char *db_name)
251{
252 _crawl_dir_t d = { 0 };
253 d.dir = g_dir_make_tmp("ansel_test_crawl_XXXXXX", NULL);
254 assert_non_null(d.dir);
255 d.raw = g_build_filename(d.dir, "shot.raw", NULL);
256 d.xmp = g_build_filename(d.dir, "shot.raw.xmp", NULL);
257 d.txt = g_build_filename(d.dir, "shot.txt", NULL);
258 assert_true(g_file_set_contents(d.raw, "", 0, NULL));
259 assert_true(g_file_set_contents(d.xmp, "<x/>", 4, NULL));
260 assert_true(g_file_set_contents(d.txt, "note", 4, NULL));
261
262 d.img = _image_with_flags(d.dir, db_name, USER_BIT);
263 assert_true(dt_image_repository_set_write_timestamp(d.img, (int64_t)g_get_real_time() / 1000000 + 86400));
264 return d;
265}
266
268{
269 g_remove(d->txt);
270 g_remove(d->xmp);
271 g_remove(d->raw);
272 g_remove(d->dir);
273 g_free(d->txt);
274 g_free(d->xmp);
275 g_free(d->raw);
276 g_free(d->dir);
277}
278
279/* The entry, not the row, is what the crawl compares against. Here the row already carries the
280 * companion bit, written behind the entry: a guard reading the row finds nothing to do, leaves
281 * the entry without the bit, and that entry's next release writes its word back over the row. */
283{
284 (void)state;
285 _crawl_dir_t d = _crawl_dir_new("shot.raw");
286
287 dt_image_t *looked = dt_image_cache_get(d.img, 'r');
288 assert_non_null(looked);
290
291 // the row gains the bit behind the entry's back, so the two now disagree on it
293
294 GList *changed = dt_control_crawler_run();
295 assert_null(changed);
296
297 // the user rates the image, and the entry's release writes its whole word back
298 dt_image_t *rated = dt_image_cache_get(d.img, 'w');
299 assert_non_null(rated);
300 assert_int_equal(rated->flags & DT_IMAGE_HAS_TXT, DT_IMAGE_HAS_TXT);
301 rated->flags |= 1; // one star
303
304 assert_true(_row_has(d.img, DT_IMAGE_HAS_TXT));
306}
307
308typedef struct _holder_t
309{
310 int32_t img;
311 GMutex lock;
312 GCond cond;
313 gboolean holding;
315
316/* Holds the entry for writing, as the GUI does while it applies a rating, and gives it back --
317 * writing its whole word to the row -- only once the crawl has had every chance to write
318 * behind it. */
319static gpointer _hold_entry(gpointer data)
320{
321 _holder_t *h = (_holder_t *)data;
322 dt_image_t *held = dt_image_cache_get(h->img, 'w');
323 held->flags |= 1; // one star, set while holding
324
325 g_mutex_lock(&h->lock);
326 h->holding = TRUE;
327 g_cond_signal(&h->cond);
328 g_mutex_unlock(&h->lock);
329
330 g_usleep(500000);
332 return NULL;
333}
334
335/* An entry someone holds is still an entry. testget() cannot tell it from no entry at all, and a
336 * crawl writing the row in that case writes behind a live entry whose release then reverts it:
337 * the crawl has to wait for the entry instead. */
339{
340 (void)state;
341 _crawl_dir_t d = _crawl_dir_new("shot.raw");
342
343 dt_image_t *looked = dt_image_cache_get(d.img, 'r');
344 assert_non_null(looked);
346
347 _holder_t h = { .img = d.img, .holding = FALSE };
348 g_mutex_init(&h.lock);
349 g_cond_init(&h.cond);
350 GThread *holder = g_thread_new("entry holder", _hold_entry, &h);
351 g_mutex_lock(&h.lock);
352 while(!h.holding) g_cond_wait(&h.cond, &h.lock);
353 g_mutex_unlock(&h.lock);
354
355 GList *changed = dt_control_crawler_run();
356 assert_null(changed);
357 g_thread_join(holder);
358
359 // both survive: the crawl's bit, and the star set while the entry was held
360 assert_true(_row_has(d.img, DT_IMAGE_HAS_TXT));
361 assert_true(_row_has(d.img, 1));
362
363 g_cond_clear(&h.cond);
364 g_mutex_clear(&h.lock);
366}
367
368/* A name the database spells differently from the disk. Where the filesystem folds case,
369 * stat() found the file, and so must the crawl; where it does not (ext4), stat() answered
370 * "missing", and the crawl must too -- rather than find `shot.raw' under the database's
371 * `SHOT.raw' and hand that image another file's companions. This checks the second half, so
372 * it needs a directory that tells the two spellings apart. */
374{
375 (void)state;
376 _crawl_dir_t d = _crawl_dir_new("SHOT.raw");
377
378 gchar *as_named = g_build_filename(d.dir, "SHOT.raw", NULL);
379 const gboolean folds_case = g_file_test(as_named, G_FILE_TEST_EXISTS);
380 g_free(as_named);
381
382 if(!folds_case)
383 {
384 GList *changed = dt_control_crawler_run();
385 assert_null(changed);
386
387 // `SHOT.raw' names no file in this directory, so `shot.txt' is not its companion
388 assert_false(_row_has(d.img, DT_IMAGE_HAS_TXT));
389 }
390
391 /* One exit, one free. skip() does not return -- it longjmps to the runner -- so freeing
392 * inside the skipped branch and again at the end would be two frees on one path for
393 * anything that cannot see CMOCKA_NORETURN, and a real double free the day someone copies
394 * this shape without the skip. The directory is cleaned up either way. */
396 if(folds_case) skip();
397}
398
399int main(void)
400{
401 const struct CMUnitTest tests[] = {
407 cmocka_unit_test(test_crawl_waits_for_a_held_entry),
409 };
410 return cmocka_run_group_tests(tests, cache_setup, cache_teardown);
411}
412
413// clang-format off
414// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
415// vim: shiftwidth=2 expandtab tabstop=2 cindent
416// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
417// clang-format on
#define TRUE
Definition ashift_lsd.c:162
#define FALSE
Definition ashift_lsd.c:158
typedef void((*dt_cache_allocate_t)(void *userdata, dt_cache_entry_t *entry))
void dt_conf_cleanup(dt_conf_t *cf)
Free everything cf owns. No dt_conf_*() call is valid afterwards.
void dt_conf_init(dt_conf_t *cf, const char *filename, GSList *override_entries)
Populate cf from filename and from the generated defaults.
GList * dt_control_crawler_run(void)
Definition crawler.c:533
darktable_t darktable
Definition darktable.c:213
GtkWidget * folder
destination folder chooser
const char flag
Definition image.h:311
@ DT_IMAGE_HAS_TXT
Definition image.h:136
void dt_image_cache_write_release(dt_image_t *img, dt_image_cache_write_mode_t mode)
dt_image_t * dt_image_cache_testget(const int32_t imgid, char mode)
void dt_image_cache_init(const gboolean verbose)
Initialise the cache.
dt_image_t * dt_image_cache_get(const int32_t imgid, char mode)
void dt_image_cache_read_release(const dt_image_t *img)
void dt_image_cache_cleanup(void)
@ DT_IMAGE_CACHE_RELAXED
Definition image_cache.h:50
gboolean dt_image_repository_set_flags_masked(const int32_t imgid, const int mask, const int value)
Write only mask's bits of imgid's flags, taking them from value.
GList * dt_image_repository_get_ids_with_flag_among(GList *imgids, const int flag)
Of the images in imgids, those whose flags carry flag, in row order.
gboolean dt_image_repository_set_group(const int32_t imgid, const int32_t group_id)
Set imgid's group_id.
gboolean dt_image_repository_set_write_timestamp(const int32_t imgid, const int64_t timestamp)
Set write_timestamp of imgid to timestamp (seconds since the epoch). Bound as a 64-bit integer; the c...
gboolean dt_image_repository_set_flags(const int32_t imgid, const int flags)
Replace imgid's whole flags word.
dt_mipmap_buffer_dsc_flags flags
Definition mipmap_cache.c:4
const char * name
Definition pdf.h:90
const float uint32_t state[4]
struct dt_conf_t * conf
Definition darktable.h:184
The whole configuration state. One instance lives at darktable.conf.
Definition conf.h:99
int32_t flags
Definition image.h:401
static void test_crawl_waits_for_a_held_entry(void **state)
static void test_cached_entry_overwrites_a_row_written_behind_it(void **state)
static _crawl_dir_t _crawl_dir_new(const char *db_name)
static gpointer _hold_entry(gpointer data)
static void test_crawl_keeps_a_cached_entry_in_step(void **state)
static int cache_teardown(void **state)
static gboolean _row_has(const int32_t imgid, const int flag)
static void test_editing_the_entry_survives_the_next_rating(void **state)
static void test_crawl_reads_a_miscased_name_as_the_filesystem_does(void **state)
static char * _rcfile
static int cache_setup(void **state)
static int32_t _image_with_flags(const char *folder, const char *name, const int flags)
static void test_testget_does_not_create_an_entry(void **state)
static void test_crawl_corrects_an_entry_the_row_already_agrees_with(void **state)
static void _crawl_dir_free(_crawl_dir_t *d)
Shared fixture for the src/database repository tests.
static int testdb_setup(void **state)
Definition testdb.h:55
static int32_t testdb_make_film(const char *folder)
Definition testdb.h:85
static int testdb_teardown(void **state)
Definition testdb.h:66
static int32_t testdb_make_image(const int32_t film_id, const char *filename)
Definition testdb.h:92