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#ifdef _WIN32
44#include "win/main_wrapper.h"
45#endif
46
47// two bits standing in for the crawl's own DT_IMAGE_HAS_TXT / DT_IMAGE_HAS_WAV
48#define CRAWL_BIT_A (1 << 4)
49#define CRAWL_BIT_B (1 << 5)
50#define CRAWL_MASK (CRAWL_BIT_A | CRAWL_BIT_B)
51// a bit standing in for what the user owns and the crawl must never touch
52#define USER_BIT 2048
53
54static char *_rcfile = NULL;
55
56static int cache_setup(void **state)
57{
58 const int rc = testdb_setup(state);
59 if(rc) return rc;
60
61 /* Populating a cache entry reads conf: dt_image_repository_load() derives the film-roll
62 * name, and dt_image_film_roll_name() asks for `show_folder_levels'. Same fixture as
63 * test_conf_value_lifetime.c -- dt_conf_init() writes through the global, so the global
64 * must point at the instance being initialised before the call. */
65 _rcfile = g_build_filename(g_get_tmp_dir(), "ansel_test_image_cache_flags.rc", NULL);
66 g_remove(_rcfile);
67 darktable.conf = (dt_conf_t *)calloc(1, sizeof(dt_conf_t));
69
71 return 0;
72}
73
74static int cache_teardown(void **state)
75{
77
79 free(darktable.conf);
80 darktable.conf = NULL;
81 g_remove(_rcfile);
82 g_free(_rcfile);
83 _rcfile = NULL;
84
85 return testdb_teardown(state);
86}
87
89static int32_t _image_with_flags(const char *folder, const char *name, const int flags)
90{
91 const int32_t film = testdb_make_film(folder);
92 assert_true(film > 0);
93 const int32_t img = testdb_make_image(film, name);
94 assert_true(img > 0);
95 /* An image imported through the application leads a group of its own;
96 * dt_image_repository_insert_import() leaves group_id NULL, and the cache's write-through
97 * rewrites the whole row -- which a NULL group_id fails as a constraint. */
98 assert_true(dt_image_repository_set_group(img, img));
99 assert_true(dt_image_repository_set_flags(img, flags));
100 return img;
101}
102
103static gboolean _row_has(const int32_t imgid, const int flag)
104{
105 GList *one = g_list_append(NULL, GINT_TO_POINTER(imgid));
107 const gboolean found = (g_list_length(got) == 1);
108 g_list_free(got);
109 g_list_free(one);
110 return found;
111}
112
113/* The hazard itself: a row written behind a cached entry does not survive that entry's next
114 * release. If this ever stops being true the crawler's care below is no longer needed -- but
115 * until then, nothing may write flags straight to the row for a cached image. */
117{
118 (void)state;
119 const int32_t img = _image_with_flags("/testdb/behind", "behind.raw", USER_BIT);
120
121 // the user looks at the image: it now has a cache entry, holding flags as the row had them
122 dt_image_t *cached = dt_image_cache_get(img, 'r');
123 assert_non_null(cached);
124 assert_int_equal(cached->flags & CRAWL_MASK, 0);
126
127 // something writes the row straight through the repository, cache none the wiser
129 assert_true(_row_has(img, CRAWL_BIT_A));
130
131 // the user rates the image: the entry is released, and writes its whole word back
132 dt_image_t *rated = dt_image_cache_get(img, 'w');
133 assert_non_null(rated);
134 rated->flags |= 1; // one star
136
137 // the row update is gone, and nothing said so
138 assert_false(_row_has(img, CRAWL_BIT_A));
139}
140
141/* The sequence control/crawler.c uses instead: edit the entry when there is one. The rating
142 * that follows then carries the crawl's bits rather than reverting them. */
144{
145 (void)state;
146 const int32_t img = _image_with_flags("/testdb/entry", "entry.raw", USER_BIT);
147
148 dt_image_t *cached = dt_image_cache_get(img, 'r');
149 assert_non_null(cached);
151
152 // the crawl finds an entry, so it edits the entry rather than the row
153 dt_image_t *edited = dt_image_cache_testget(img, 'w');
154 assert_non_null(edited);
155 edited->flags = (edited->flags & ~CRAWL_MASK) | CRAWL_BIT_A;
157 assert_true(_row_has(img, CRAWL_BIT_A));
158
159 // the user rates it afterwards
160 dt_image_t *rated = dt_image_cache_get(img, 'w');
161 assert_non_null(rated);
162 rated->flags |= 1;
164
165 // both survive: the crawl's bit and the bit the user owns
166 assert_true(_row_has(img, CRAWL_BIT_A));
167 assert_true(_row_has(img, USER_BIT));
168}
169
170/* testget() must not create an entry: a crawl over the whole library calls it once per image,
171 * and an allocating answer would pull the whole library into a 50 MiB cache. */
173{
174 (void)state;
175 const int32_t img = _image_with_flags("/testdb/uncached", "uncached.raw", USER_BIT);
176
177 assert_null(dt_image_cache_testget(img, 'w'));
178
179 // so the crawl writes the row, and that write stands
181 assert_true(_row_has(img, CRAWL_BIT_B));
182 assert_true(_row_has(img, USER_BIT));
183}
184
185/* The crawl itself, over a real directory, on an image the user has already looked at.
186 *
187 * This is the sequence the fix exists for: the crawl notices a companion .txt, and the entry
188 * sitting in the cache must come away carrying that -- otherwise the next rating writes the
189 * entry's stale word back over the row. */
191{
192 (void)state;
193 gchar *dir = g_dir_make_tmp("ansel_test_crawl_XXXXXX", NULL);
194 assert_non_null(dir);
195 gchar *raw = g_build_filename(dir, "shot.raw", NULL);
196 gchar *txt = g_build_filename(dir, "shot.txt", NULL);
197 /* Version 0 takes no `_NN' suffix, so the sidecar is `<name>.xmp'. It has to exist at all:
198 * _crawl_image() gives up on an image with no sidecar before it ever looks for companion
199 * files, which is the behaviour the per-file stat() version had too. */
200 gchar *xmp = g_build_filename(dir, "shot.raw.xmp", NULL);
201 assert_true(g_file_set_contents(raw, "", 0, NULL));
202 assert_true(g_file_set_contents(txt, "note", 4, NULL));
203 assert_true(g_file_set_contents(xmp, "<x/>", 4, NULL));
204
205 const int32_t img = _image_with_flags(dir, "shot.raw", USER_BIT);
206 assert_false(_row_has(img, DT_IMAGE_HAS_TXT));
207
208 /* Date the row after the sidecar, so the crawl has nothing to report and the only thing it
209 * does on this image is the companion-flags update this test is about. */
210 assert_true(dt_image_repository_set_write_timestamp(img, (int64_t)g_get_real_time() / 1000000 + 86400));
211
212 // the user has looked at this image, so it has a cache entry -- holding no HAS_TXT
213 dt_image_t *looked = dt_image_cache_get(img, 'r');
214 assert_non_null(looked);
215 assert_int_equal(looked->flags & DT_IMAGE_HAS_TXT, 0);
217
218 // the row is dated after the sidecar, so the crawl reports nothing and only updates the
219 // companion flags
220 GList *changed = dt_control_crawler_run();
221 assert_null(changed);
222
223 assert_true(_row_has(img, DT_IMAGE_HAS_TXT));
224
225 // and the entry knows it too, which is the whole point: rating the image now keeps it
226 dt_image_t *rated = dt_image_cache_get(img, 'w');
227 assert_non_null(rated);
228 assert_int_equal(rated->flags & DT_IMAGE_HAS_TXT, DT_IMAGE_HAS_TXT);
229 rated->flags |= 1; // one star
231
232 assert_true(_row_has(img, DT_IMAGE_HAS_TXT));
233 assert_true(_row_has(img, USER_BIT));
234
235 g_remove(xmp);
236 g_remove(txt);
237 g_remove(raw);
238 g_remove(dir);
239 g_free(xmp);
240 g_free(txt);
241 g_free(raw);
242 g_free(dir);
243}
244
245/* A directory holding `shot.raw', its sidecar and a companion `shot.txt', and an image row
246 * named @p db_name there, dated after the sidecar: a crawl then has nothing to report on it,
247 * and the only thing it does is the companion-flags update these tests are about. */
248typedef struct _crawl_dir_t
249{
250 gchar *dir, *raw, *xmp, *txt;
251 int32_t img;
253
254static _crawl_dir_t _crawl_dir_new(const char *db_name)
255{
256 _crawl_dir_t d = { 0 };
257 d.dir = g_dir_make_tmp("ansel_test_crawl_XXXXXX", NULL);
258 assert_non_null(d.dir);
259 d.raw = g_build_filename(d.dir, "shot.raw", NULL);
260 d.xmp = g_build_filename(d.dir, "shot.raw.xmp", NULL);
261 d.txt = g_build_filename(d.dir, "shot.txt", NULL);
262 assert_true(g_file_set_contents(d.raw, "", 0, NULL));
263 assert_true(g_file_set_contents(d.xmp, "<x/>", 4, NULL));
264 assert_true(g_file_set_contents(d.txt, "note", 4, NULL));
265
266 d.img = _image_with_flags(d.dir, db_name, USER_BIT);
267 assert_true(dt_image_repository_set_write_timestamp(d.img, (int64_t)g_get_real_time() / 1000000 + 86400));
268 return d;
269}
270
272{
273 g_remove(d->txt);
274 g_remove(d->xmp);
275 g_remove(d->raw);
276 g_remove(d->dir);
277 g_free(d->txt);
278 g_free(d->xmp);
279 g_free(d->raw);
280 g_free(d->dir);
281}
282
283/* The entry, not the row, is what the crawl compares against. Here the row already carries the
284 * companion bit, written behind the entry: a guard reading the row finds nothing to do, leaves
285 * the entry without the bit, and that entry's next release writes its word back over the row. */
287{
288 (void)state;
289 _crawl_dir_t d = _crawl_dir_new("shot.raw");
290
291 dt_image_t *looked = dt_image_cache_get(d.img, 'r');
292 assert_non_null(looked);
294
295 // the row gains the bit behind the entry's back, so the two now disagree on it
297
298 GList *changed = dt_control_crawler_run();
299 assert_null(changed);
300
301 // the user rates the image, and the entry's release writes its whole word back
302 dt_image_t *rated = dt_image_cache_get(d.img, 'w');
303 assert_non_null(rated);
304 assert_int_equal(rated->flags & DT_IMAGE_HAS_TXT, DT_IMAGE_HAS_TXT);
305 rated->flags |= 1; // one star
307
308 assert_true(_row_has(d.img, DT_IMAGE_HAS_TXT));
310}
311
312typedef struct _holder_t
313{
314 int32_t img;
315 GMutex lock;
316 GCond cond;
317 gboolean holding;
319
320/* Holds the entry for writing, as the GUI does while it applies a rating, and gives it back --
321 * writing its whole word to the row -- only once the crawl has had every chance to write
322 * behind it. */
323static gpointer _hold_entry(gpointer data)
324{
325 _holder_t *h = (_holder_t *)data;
326 dt_image_t *held = dt_image_cache_get(h->img, 'w');
327 held->flags |= 1; // one star, set while holding
328
329 g_mutex_lock(&h->lock);
330 h->holding = TRUE;
331 g_cond_signal(&h->cond);
332 g_mutex_unlock(&h->lock);
333
334 g_usleep(500000);
336 return NULL;
337}
338
339/* An entry someone holds is still an entry. testget() cannot tell it from no entry at all, and a
340 * crawl writing the row in that case writes behind a live entry whose release then reverts it:
341 * the crawl has to wait for the entry instead. */
343{
344 (void)state;
345 _crawl_dir_t d = _crawl_dir_new("shot.raw");
346
347 dt_image_t *looked = dt_image_cache_get(d.img, 'r');
348 assert_non_null(looked);
350
351 _holder_t h = { .img = d.img, .holding = FALSE };
352 g_mutex_init(&h.lock);
353 g_cond_init(&h.cond);
354 GThread *holder = g_thread_new("entry holder", _hold_entry, &h);
355 g_mutex_lock(&h.lock);
356 while(!h.holding) g_cond_wait(&h.cond, &h.lock);
357 g_mutex_unlock(&h.lock);
358
359 GList *changed = dt_control_crawler_run();
360 assert_null(changed);
361 g_thread_join(holder);
362
363 // both survive: the crawl's bit, and the star set while the entry was held
364 assert_true(_row_has(d.img, DT_IMAGE_HAS_TXT));
365 assert_true(_row_has(d.img, 1));
366
367 g_cond_clear(&h.cond);
368 g_mutex_clear(&h.lock);
370}
371
372/* A name the database spells differently from the disk. Where the filesystem folds case,
373 * stat() found the file, and so must the crawl; where it does not (ext4), stat() answered
374 * "missing", and the crawl must too -- rather than find `shot.raw' under the database's
375 * `SHOT.raw' and hand that image another file's companions. This checks the second half, so
376 * it needs a directory that tells the two spellings apart. */
378{
379 (void)state;
380 _crawl_dir_t d = _crawl_dir_new("SHOT.raw");
381
382 gchar *as_named = g_build_filename(d.dir, "SHOT.raw", NULL);
383 const gboolean folds_case = g_file_test(as_named, G_FILE_TEST_EXISTS);
384 g_free(as_named);
385
386 if(!folds_case)
387 {
388 GList *changed = dt_control_crawler_run();
389 assert_null(changed);
390
391 // `SHOT.raw' names no file in this directory, so `shot.txt' is not its companion
392 assert_false(_row_has(d.img, DT_IMAGE_HAS_TXT));
393 }
394
395 /* One exit, one free. skip() does not return -- it longjmps to the runner -- so freeing
396 * inside the skipped branch and again at the end would be two frees on one path for
397 * anything that cannot see CMOCKA_NORETURN, and a real double free the day someone copies
398 * this shape without the skip. The directory is cleaned up either way. */
400 if(folds_case) skip();
401}
402
403int main(int argc, char *argv[])
404{
405 const struct CMUnitTest tests[] = {
411 cmocka_unit_test(test_crawl_waits_for_a_held_entry),
413 };
414 return cmocka_run_group_tests(tests, cache_setup, cache_teardown);
415}
416
417// clang-format off
418// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
419// vim: shiftwidth=2 expandtab tabstop=2 cindent
420// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
421// 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:320
@ DT_IMAGE_HAS_TXT
Definition image.h:145
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
int main()
Definition prova.c:47
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.
int32_t flags
Definition image.h:410
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