diff --git a/tests/js/prompt-list-sort.test.js b/tests/js/prompt-list-sort.test.js new file mode 100644 index 0000000..c530ad0 --- /dev/null +++ b/tests/js/prompt-list-sort.test.js @@ -0,0 +1,66 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const sort = require("../../web/js/prompt-list-sort.js"); + +test("default sort is recently used and is a listed option", () => { + assert.equal(sort.DEFAULT_SORT, "last_used_desc"); + assert.ok(sort.SORT_OPTIONS.some((o) => o.value === sort.DEFAULT_SORT)); +}); + +test("options include Recently used and Most used, each with a label", () => { + const values = sort.SORT_OPTIONS.map((o) => o.value); + assert.ok(values.includes("last_used_desc")); + assert.ok(values.includes("run_count_desc")); + for (const option of sort.SORT_OPTIONS) assert.ok(option.label.trim(), option.value); + assert.equal(new Set(values).size, values.length, "values are unique"); +}); + +test("options cannot be mutated by callers", () => { + assert.throws(() => sort.SORT_OPTIONS.push({ value: "x", label: "x" })); + // Non-strict code fails silently on frozen objects, so check the value survived + const first = sort.SORT_OPTIONS[0].value; + sort.SORT_OPTIONS[0].value = "hacked"; + assert.equal(sort.SORT_OPTIONS[0].value, first); + assert.ok(sort.SORT_OPTIONS.every(Object.isFrozen)); +}); + +test("normalizeSort keeps known values and falls back for anything else", () => { + assert.equal(sort.normalizeSort("run_count_desc"), "run_count_desc"); + for (const bad of [undefined, null, "", "created_at; DROP TABLE prompts", 42, "LAST_USED_DESC"]) { + assert.equal(sort.normalizeSort(bad), sort.DEFAULT_SORT, String(bad)); + } +}); + +test("buildRecentUrl computes offset from page and always sends a valid sort", () => { + assert.equal( + sort.buildRecentUrl({ page: 3, limit: 25, sort: "rating_desc" }), + "/prompt_manager/recent?limit=25&offset=50&page=3&sort=rating_desc", + ); + assert.equal( + sort.buildRecentUrl({ sort: "bogus" }), + "/prompt_manager/recent?limit=50&offset=0&page=1&sort=last_used_desc", + ); +}); + +test("withSort returns a copy with the sort set and leaves the input untouched", () => { + const params = new URLSearchParams({ text: "castle", sort: "text_asc" }); + const result = sort.withSort(params, "run_count_desc"); + assert.equal(result.get("sort"), "run_count_desc"); + assert.equal(result.get("text"), "castle"); + assert.equal(params.get("sort"), "text_asc"); + assert.equal(sort.withSort(new URLSearchParams(), "nope").get("sort"), sort.DEFAULT_SORT); +}); + +test("formatRunCount labels zero, one and many runs", () => { + assert.equal(sort.formatRunCount(0), "Never run"); + assert.equal(sort.formatRunCount(1), "1 run"); + assert.equal(sort.formatRunCount(642), "642 runs"); + assert.equal(sort.formatRunCount(1234), "1,234 runs"); +}); + +test("formatRunCount hides the badge for missing or invalid counts", () => { + for (const bad of [undefined, null, -1, 1.5, "3", NaN]) { + assert.equal(sort.formatRunCount(bad), "", String(bad)); + } +}); diff --git a/tests/test_sort_contract.py b/tests/test_sort_contract.py new file mode 100644 index 0000000..103815f --- /dev/null +++ b/tests/test_sort_contract.py @@ -0,0 +1,39 @@ +"""The UI's sort options must match the server's whitelist exactly.""" + +import os +import re +import sys +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from database.operations import SORT_ORDERS + +SORT_MODULE = os.path.join(ROOT, "web", "js", "prompt-list-sort.js") +NODE_EXTENSION = os.path.join(ROOT, "web", "prompt_manager.js") + + +class TestSortContract(unittest.TestCase): + def test_ui_sort_options_match_server_whitelist(self): + with open(SORT_MODULE, encoding="utf-8") as fh: + source = fh.read() + ui_values = set(re.findall(r'value:\s*"([a-z_]+)"', source)) + + self.assertTrue(ui_values, "no sort options found in prompt-list-sort.js") + self.assertEqual(ui_values, set(SORT_ORDERS)) + + def test_node_recent_button_requests_recently_used(self): + # The node extension can't import the shared module (ComfyUI load order is + # not guaranteed), so it sends the key directly; it must be a server key. + with open(NODE_EXTENSION, encoding="utf-8") as fh: + source = fh.read() + match = re.search(r"/prompt_manager/recent\?[^`'\"]*sort=([a-z_]+)", source) + + self.assertIsNotNone(match, "node Recent request does not send a sort") + self.assertEqual(match.group(1), "last_used_desc") + self.assertIn(match.group(1), SORT_ORDERS) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/admin.html b/web/admin.html index 6bb850a..6431ebc 100644 --- a/web/admin.html +++ b/web/admin.html @@ -107,12 +107,8 @@