#define _POSIX_C_SOURCE 200809L #include "kd_deck.h" #include #include #include #include #include #include static int write_file(const char *path, const char *contents) { FILE *file; file = fopen(path, "w"); if (file == NULL) { return 0; } if (fputs(contents, file) == EOF) { (void)fclose(file); return 0; } return fclose(file) == 0; } static int remove_tree(const char *root) { char path[1024]; int failed; failed = 0; (void)snprintf(path, sizeof(path), "%s/cards/1/jukugo/1.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/jukugo/1_ans.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/jukugo/2.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/jukugo/2_ans.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/jukugo", root); if (rmdir(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/kanji.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/kanji_ans.ppm", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1/URL.txt", root); if (unlink(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards/1", root); if (rmdir(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/cards", root); if (rmdir(path) != 0) failed = 1; (void)snprintf(path, sizeof(path), "%s/progress.txt", root); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/progress.txt.tmp", root); (void)unlink(path); if (rmdir(root) != 0) failed = 1; return failed == 0; } static size_t find_key(const KDDeck *deck, const char *key) { size_t i; for (i = 0U; i < deck->item_count; i++) { if (strcmp(deck->items[i].key, key) == 0) { return i; } } return (size_t)-1; } static int create_order_card(const char *cards_path, const char *id, int jukugo_count) { char card_path[1024]; char jukugo_path[1024]; char path[1024]; char filename[64]; int i; (void)snprintf(card_path, sizeof(card_path), "%s/%s", cards_path, id); (void)snprintf(jukugo_path, sizeof(jukugo_path), "%s/jukugo", card_path); if (mkdir(card_path, 0700) != 0 || mkdir(jukugo_path, 0700) != 0) { return 0; } (void)snprintf(path, sizeof(path), "%s/kanji.ppm", card_path); if (!write_file(path, "P3\n1 1\n255\n255 255 255\n")) return 0; (void)snprintf(path, sizeof(path), "%s/kanji_ans.ppm", card_path); if (!write_file(path, "P3\n1 1\n255\n255 255 255\n")) return 0; (void)snprintf(path, sizeof(path), "%s/URL.txt", card_path); if (!write_file(path, "https://www.example.com/kanji/example\n")) return 0; for (i = 1; i <= jukugo_count; i++) { (void)snprintf(filename, sizeof(filename), "%d.ppm", i); (void)snprintf(path, sizeof(path), "%s/%s", jukugo_path, filename); if (!write_file(path, "P3\n1 1\n255\n0 0 0\n")) return 0; (void)snprintf(filename, sizeof(filename), "%d_ans.ppm", i); (void)snprintf(path, sizeof(path), "%s/%s", jukugo_path, filename); if (!write_file(path, "P3\n1 1\n255\n255 255 255\n")) return 0; } return 1; } static void remove_order_fixture(const char *root) { char path[1024]; int card; int jukugo; for (card = 1; card <= 2; card++) { for (jukugo = 1; jukugo <= 2; jukugo++) { (void)snprintf(path, sizeof(path), "%s/cards/%d/jukugo/%d.ppm", root, card, jukugo); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/cards/%d/jukugo/%d_ans.ppm", root, card, jukugo); (void)unlink(path); } (void)snprintf(path, sizeof(path), "%s/cards/%d/jukugo", root, card); (void)rmdir(path); (void)snprintf(path, sizeof(path), "%s/cards/%d/kanji.ppm", root, card); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/cards/%d/kanji_ans.ppm", root, card); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/cards/%d/URL.txt", root, card); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/cards/%d", root, card); (void)rmdir(path); } (void)snprintf(path, sizeof(path), "%s/cards", root); (void)rmdir(path); (void)snprintf(path, sizeof(path), "%s/progress.txt", root); (void)unlink(path); (void)snprintf(path, sizeof(path), "%s/progress.txt.tmp", root); (void)unlink(path); (void)rmdir(root); } static int test_new_prompt_order(void) { char template_path[] = "/tmp/kd-deck-order-test-XXXXXX"; char cards_path[1024]; char progress_path[1024]; char *root; KDDeck deck; size_t selected; size_t step; time_t now; static const char *expected[] = { "K:1", "J:1:1", "J:1:2", "K:2", "J:2:1" }; int passed; root = mkdtemp(template_path); if (root == NULL) { (void)fprintf(stderr, "FAIL: creating prompt-order fixture\n"); return 0; } (void)snprintf(cards_path, sizeof(cards_path), "%s/cards", root); (void)snprintf(progress_path, sizeof(progress_path), "%s/progress.txt", root); if (mkdir(cards_path, 0700) != 0 || !create_order_card(cards_path, "1", 2) || !create_order_card(cards_path, "2", 1)) { (void)fprintf(stderr, "FAIL: creating multi-card prompt-order fixture\n"); remove_order_fixture(root); return 0; } kd_deck_init(&deck); passed = kd_deck_scan(&deck, cards_path, progress_path); now = time(NULL); for (step = 0U; passed && step < sizeof(expected) / sizeof(expected[0]); step++) { if (!kd_select_next(&deck, now, &selected) || strcmp(deck.items[selected].key, expected[step]) != 0) { (void)fprintf(stderr, "FAIL: new prompt order at step %lu: expected %s\n", (unsigned long)step, expected[step]); passed = 0; break; } kd_grade(&deck, selected, 1, now); } if (passed && kd_select_next(&deck, now, &selected)) { (void)fprintf(stderr, "FAIL: a just-reviewed prompt was selected again before due\n"); passed = 0; } kd_deck_free(&deck); remove_order_fixture(root); if (passed) { (void)printf("PASS: new prompts are introduced by numeric kanji group: K:1, its jukugo, K:2, its jukugo\n"); } return passed; } int main(void) { char template_path[] = "/tmp/kd-deck-test-XXXXXX"; char cards_path[1024]; char card_path[1024]; char jukugo_path[1024]; char path[1024]; char progress_path[1024]; char *root; KDDeck deck; size_t selected; size_t numbered_item; size_t kanji_item; size_t jukugo_one; size_t jukugo_two; long kanji_due; long jukugo_one_due; long jukugo_two_due; long saved_lapses; time_t now; const char *url; int ok; if (!test_new_prompt_order()) { return EXIT_FAILURE; } root = mkdtemp(template_path); if (root == NULL) { (void)fprintf(stderr, "FAIL: mkdtemp\n"); return EXIT_FAILURE; } (void)snprintf(cards_path, sizeof(cards_path), "%s/cards", root); (void)snprintf(card_path, sizeof(card_path), "%s/cards/1", root); (void)snprintf(jukugo_path, sizeof(jukugo_path), "%s/cards/1/jukugo", root); (void)snprintf(progress_path, sizeof(progress_path), "%s/progress.txt", root); if (mkdir(cards_path, 0700) != 0 || mkdir(card_path, 0700) != 0 || mkdir(jukugo_path, 0700) != 0) { (void)fprintf(stderr, "FAIL: creating fixture directories\n"); return EXIT_FAILURE; } (void)snprintf(path, sizeof(path), "%s/kanji.ppm", card_path); ok = write_file(path, "P3\n1 1\n255\n255 255 255\n"); (void)snprintf(path, sizeof(path), "%s/kanji_ans.ppm", card_path); ok = ok && write_file(path, "P3\n1 1\n255\n255 255 255\n"); (void)snprintf(path, sizeof(path), "%s/URL.txt", card_path); ok = ok && write_file(path, "https://www.example.com/kanji/example\n"); (void)snprintf(path, sizeof(path), "%s/1.ppm", jukugo_path); ok = ok && write_file(path, "P3\n1 1\n255\n0 0 0\n"); (void)snprintf(path, sizeof(path), "%s/1_ans.ppm", jukugo_path); ok = ok && write_file(path, "P3\n1 1\n255\n255 255 255\n"); (void)snprintf(path, sizeof(path), "%s/2.ppm", jukugo_path); ok = ok && write_file(path, "P3\n1 1\n255\n0 0 0\n"); (void)snprintf(path, sizeof(path), "%s/2_ans.ppm", jukugo_path); ok = ok && write_file(path, "P3\n1 1\n255\n255 255 255\n"); if (!ok) { (void)fprintf(stderr, "FAIL: creating fixture files\n"); (void)remove_tree(root); return EXIT_FAILURE; } kd_deck_init(&deck); if (!kd_deck_scan(&deck, cards_path, progress_path)) { (void)fprintf(stderr, "FAIL: scanning fixture deck\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (deck.card_count != 1U || deck.item_count != 3U) { (void)fprintf(stderr, "FAIL: expected one kanji and two jukugo prompts\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } url = kd_card_url(&deck.cards[0]); if (url == NULL || strstr(url, "example.com") == NULL) { (void)fprintf(stderr, "FAIL: URL.txt was not read\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (!deck.cards[0].has_answer_image) { (void)fprintf(stderr, "FAIL: kanji_ans.ppm was not detected\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (kd_count_unintroduced(&deck) != 1U) { (void)fprintf(stderr, "FAIL: new kanji count was incorrect (jukugo must not count)\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (!kd_find_kanji_item_by_number(&deck, "1", &numbered_item) || deck.items[numbered_item].jukugo_index != KD_NO_JUKUGO) { (void)fprintf(stderr, "FAIL: typed number did not resolve to the main kanji\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (kd_find_kanji_item_by_number(&deck, "2", &numbered_item) || kd_find_kanji_item_by_number(&deck, "0", &numbered_item) || kd_find_kanji_item_by_number(&deck, "1x", &numbered_item)) { (void)fprintf(stderr, "FAIL: invalid/nonexistent kanji number was accepted\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (kd_count_unintroduced(&deck) != 1U) { (void)fprintf(stderr, "FAIL: choosing a kanji by number changed its introduction state\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } now = time(NULL); if (!kd_select_next(&deck, now, &selected) || deck.items[selected].jukugo_index != KD_NO_JUKUGO) { (void)fprintf(stderr, "FAIL: scheduler did not prioritize the main kanji\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kanji_item = selected; kd_grade(&deck, kanji_item, 1, now); if (deck.reviews[deck.items[kanji_item].review_index].interval_days != 1L) { (void)fprintf(stderr, "FAIL: first successful interval is not one day\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kanji_due = deck.reviews[deck.items[kanji_item].review_index].due_epoch; if (!kd_select_next(&deck, now, &selected) || strcmp(deck.items[selected].key, "J:1:1") != 0) { (void)fprintf(stderr, "FAIL: first jukugo was not independently selected\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } jukugo_one = selected; kd_grade(&deck, jukugo_one, 1, now); jukugo_one_due = deck.reviews[deck.items[jukugo_one].review_index].due_epoch; if (!kd_select_next(&deck, now, &selected) || strcmp(deck.items[selected].key, "J:1:2") != 0) { (void)fprintf(stderr, "FAIL: second jukugo was not independently selected\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } jukugo_two = selected; now += (time_t)100; kd_grade(&deck, jukugo_two, 0, now); if (deck.reviews[deck.items[kanji_item].review_index].due_epoch != kanji_due || deck.reviews[deck.items[jukugo_one].review_index].due_epoch != jukugo_one_due || deck.reviews[deck.items[jukugo_two].review_index].due_epoch != (long)now + 600L) { (void)fprintf(stderr, "FAIL: forgetting one jukugo changed a sibling or kanji schedule\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } now += (time_t)1; saved_lapses = deck.reviews[deck.items[jukugo_two].review_index].lapses; if (!kd_relearn_prompt(&deck, jukugo_two, now) || deck.reviews[deck.items[jukugo_two].review_index].interval_days != 1L || !deck.reviews[deck.items[jukugo_two].review_index].relearning || deck.reviews[deck.items[jukugo_two].review_index].due_epoch != (long)now + 600L || deck.reviews[deck.items[jukugo_two].review_index].lapses != saved_lapses + 1L || deck.reviews[deck.items[kanji_item].review_index].due_epoch != kanji_due || deck.reviews[deck.items[jukugo_one].review_index].due_epoch != jukugo_one_due) { (void)fprintf(stderr, "FAIL: prompt-only relearning changed other schedules\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kd_grade(&deck, jukugo_two, 1, now + (time_t)600); if (deck.reviews[deck.items[jukugo_two].review_index].interval_days != 1L || deck.reviews[deck.items[jukugo_two].review_index].relearning || deck.reviews[deck.items[jukugo_two].review_index].due_epoch != (long)now + 600L + 86400L) { (void)fprintf(stderr, "FAIL: successful relearning did not graduate at the 1-day interval\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } jukugo_one_due = deck.reviews[deck.items[jukugo_one].review_index].due_epoch; jukugo_two_due = deck.reviews[deck.items[jukugo_two].review_index].due_epoch; now += (time_t)10; kd_grade(&deck, kanji_item, 0, now); if (deck.reviews[deck.items[kanji_item].review_index].due_epoch != (long)now + 600L || deck.reviews[deck.items[jukugo_one].review_index].due_epoch != jukugo_one_due || deck.reviews[deck.items[jukugo_two].review_index].due_epoch != jukugo_two_due) { (void)fprintf(stderr, "FAIL: forgetting the kanji changed its jukugo schedules\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } saved_lapses = deck.reviews[deck.items[jukugo_two].review_index].lapses; now += (time_t)100; if (!kd_relearn_card(&deck, 0U, now)) { (void)fprintf(stderr, "FAIL: whole-kanji relearning could not save\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } if (deck.reviews[deck.items[kanji_item].review_index].due_epoch != (long)now + 600L || deck.reviews[deck.items[kanji_item].review_index].interval_days != 1L || !deck.reviews[deck.items[kanji_item].review_index].relearning || deck.reviews[deck.items[jukugo_one].review_index].due_epoch != (long)now + 600L || deck.reviews[deck.items[jukugo_two].review_index].due_epoch != (long)now + 600L || deck.reviews[deck.items[jukugo_two].review_index].lapses != saved_lapses + 1L) { (void)fprintf(stderr, "FAIL: whole-kanji relearning did not reset the full set correctly\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kd_deck_free(&deck); kd_deck_init(&deck); if (!kd_deck_scan(&deck, cards_path, progress_path) || deck.card_count != 1U || deck.item_count != 3U) { (void)fprintf(stderr, "FAIL: rescanning after progress save\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kanji_item = find_key(&deck, "K:1"); jukugo_one = find_key(&deck, "J:1:1"); jukugo_two = find_key(&deck, "J:1:2"); if (kanji_item == (size_t)-1 || jukugo_one == (size_t)-1 || jukugo_two == (size_t)-1 || deck.reviews[deck.items[kanji_item].review_index].interval_days != 1L || deck.reviews[deck.items[jukugo_one].review_index].interval_days != 1L || deck.reviews[deck.items[jukugo_two].review_index].interval_days != 1L) { (void)fprintf(stderr, "FAIL: relearning schedule reset did not persist\n"); kd_deck_free(&deck); (void)remove_tree(root); return EXIT_FAILURE; } kd_deck_free(&deck); if (!remove_tree(root)) { (void)fprintf(stderr, "WARNING: could not remove every temporary test file\n"); } (void)printf("PASS: independent kanji/jukugo schedules, Anki-default lapse interval and relearning steps, whole-kanji group relearn, typed-number lookup, answer images, URL.txt, and persistence\n"); return EXIT_SUCCESS; }