diff --git a/locales/de.json b/locales/de.json index bd27163c..8fe743a2 100644 --- a/locales/de.json +++ b/locales/de.json @@ -714,7 +714,9 @@ "versionsCount": "Lokale Versionen", "versionsCountDesc": "Meiste Versionen zuerst", "versionsCountAsc": "Wenigste Versionen zuerst", - "versionIdDesc": "Neueste Version zuerst" + "versionIdDesc": "Neueste Version zuerst", + "random": "Zufällig", + "randomAction": "Zufällig mischen" }, "refresh": { "title": "Modelliste aktualisieren", diff --git a/locales/en.json b/locales/en.json index e8c0bc9b..0505dd5d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -714,7 +714,9 @@ "versionsCount": "Local Versions", "versionsCountDesc": "Most versions first", "versionsCountAsc": "Fewest versions first", - "versionIdDesc": "Newest version first" + "versionIdDesc": "Newest version first", + "random": "Random", + "randomAction": "Randomize (shuffle)" }, "refresh": { "title": "Refresh model list", diff --git a/locales/es.json b/locales/es.json index b84766e8..e608aaab 100644 --- a/locales/es.json +++ b/locales/es.json @@ -714,7 +714,9 @@ "versionsCount": "Versiones locales", "versionsCountDesc": "Más versiones primero", "versionsCountAsc": "Menos versiones primero", - "versionIdDesc": "Versión más nueva primero" + "versionIdDesc": "Versión más nueva primero", + "random": "Aleatorio", + "randomAction": "Aleatorizar (barajar)" }, "refresh": { "title": "Actualizar lista de modelos", diff --git a/locales/fr.json b/locales/fr.json index 2c82587b..666f05ce 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -714,7 +714,9 @@ "versionsCount": "Versions locales", "versionsCountDesc": "Plus de versions d'abord", "versionsCountAsc": "Moins de versions d'abord", - "versionIdDesc": "Version la plus récente d'abord" + "versionIdDesc": "Version la plus récente d'abord", + "random": "Aléatoire", + "randomAction": "Aléatoire (mélanger)" }, "refresh": { "title": "Actualiser la liste des modèles", diff --git a/locales/he.json b/locales/he.json index e8f316fa..27e955ad 100644 --- a/locales/he.json +++ b/locales/he.json @@ -714,7 +714,9 @@ "versionsCount": "גרסאות מקומיות", "versionsCountDesc": "הכי הרבה גרסאות ראשונות", "versionsCountAsc": "הכי מעט גרסאות ראשונות", - "versionIdDesc": "גרסה חדשה ביותר ראשונה" + "versionIdDesc": "גרסה חדשה ביותר ראשונה", + "random": "אקראי", + "randomAction": "ערבוב אקראי" }, "refresh": { "title": "רענן רשימת מודלים", diff --git a/locales/ja.json b/locales/ja.json index 909de2c5..cedb09be 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -714,7 +714,9 @@ "versionsCount": "ローカルバージョン数", "versionsCountDesc": "バージョン数の多い順", "versionsCountAsc": "バージョン数の少ない順", - "versionIdDesc": "最新バージョン順" + "versionIdDesc": "最新バージョン順", + "random": "ランダム", + "randomAction": "シャッフル(ランダム)" }, "refresh": { "title": "モデルリストを更新", diff --git a/locales/ko.json b/locales/ko.json index 6ebcc6d6..1403381d 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -714,7 +714,9 @@ "versionsCount": "로컬 버전 수", "versionsCountDesc": "버전 수 많은 순", "versionsCountAsc": "버전 수 적은 순", - "versionIdDesc": "최신 버전순" + "versionIdDesc": "최신 버전순", + "random": "랜덤", + "randomAction": "셔플 (무작위)" }, "refresh": { "title": "모델 목록 새로고침", diff --git a/locales/ru.json b/locales/ru.json index d52349e7..292206ec 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -714,7 +714,9 @@ "versionsCount": "Локальные версии", "versionsCountDesc": "Сначала больше версий", "versionsCountAsc": "Сначала меньше версий", - "versionIdDesc": "Сначала новые версии" + "versionIdDesc": "Сначала новые версии", + "random": "Случайно", + "randomAction": "Перемешать" }, "refresh": { "title": "Обновить список моделей", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 22084055..b3eee4dd 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -714,7 +714,9 @@ "versionsCount": "本地版本数", "versionsCountDesc": "版本数从多到少", "versionsCountAsc": "版本数从少到多", - "versionIdDesc": "最新版本优先" + "versionIdDesc": "最新版本优先", + "random": "随机", + "randomAction": "随机排序(洗牌)" }, "refresh": { "title": "刷新模型列表", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index fa3db172..71f59cc5 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -714,7 +714,9 @@ "versionsCount": "本地版本數", "versionsCountDesc": "版本數從多到少", "versionsCountAsc": "版本數從少到多", - "versionIdDesc": "最新版本優先" + "versionIdDesc": "最新版本優先", + "random": "隨機", + "randomAction": "隨機排序(洗牌)" }, "refresh": { "title": "重新整理模型列表", diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index f85483eb..300bae66 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod import asyncio import re +import random from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING import logging import os @@ -390,6 +391,12 @@ class BaseModelService(ABC): (item.get("model_name") or item.get("file_name") or "").lower(), item.get("file_path", "").lower(), ) + elif key_name == "random": + # Seeded random shuffle: same seed -> same order (stable pagination) + rng = random.Random(sort_params.seed or "random") + result = list(data) + rng.shuffle(result) + return result elif key_name == "size": key_fn = lambda item: ( int(item.get("size", 0) or 0), diff --git a/py/services/model_cache.py b/py/services/model_cache.py index f76e6ab7..deb5cc6e 100644 --- a/py/services/model_cache.py +++ b/py/services/model_cache.py @@ -1,6 +1,7 @@ import asyncio import time import logging +import random logger = logging.getLogger(__name__) from typing import Any, Dict, List, Optional, Tuple @@ -38,8 +39,8 @@ class ModelCache: def __post_init__(self): self._lock = asyncio.Lock() - # Cache for last sort: (sort_key, order) -> sorted list - self._last_sort: Tuple[str, str] = (None, None) + # Cache for last sort: (sort_key, order, seed) -> sorted list + self._last_sort: Tuple[Optional[str], str, Optional[str]] = (None, "asc", None) self._last_sorted_data: List[Dict] = [] self._normalize_raw_data() self.name_display_mode = self._normalize_display_mode(self.name_display_mode) @@ -203,9 +204,9 @@ class ModelCache: async def resort(self): """Resort cached data according to last sort mode if set""" async with self._lock: - if self._last_sort != (None, None): - sort_key, order = self._last_sort - sorted_data = self._sort_data(self.raw_data, sort_key, order) + if self._last_sort[0] is not None: + sort_key, order, seed = self._last_sort + sorted_data = self._sort_data(self.raw_data, sort_key, order, seed) self._last_sorted_data = sorted_data # Update folder list # else: do nothing @@ -218,7 +219,7 @@ class ModelCache: self.folders = sorted(list(all_folders), key=lambda x: x.lower()) self.rebuild_version_index() - def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]: + def _sort_data(self, data: List[Dict], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict]: """Sort data by sort_key and order""" start_time = time.perf_counter() reverse = (order == 'desc') @@ -265,6 +266,13 @@ class ModelCache: ), reverse=reverse ) + elif sort_key == 'random': + # Random shuffle seeded for stable pagination: the same seed + # always yields the same order, so successive page requests + # stay consistent while browsing. + rng = random.Random(seed or 'random') + result = list(data) + rng.shuffle(result) elif sort_key == 'versions_count': # Pre-dedup sort: fall back to name sort. # Actual re-sort by version_count happens in get_paginated_data after dedup. @@ -285,15 +293,16 @@ class ModelCache: logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration) return result - async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]: + async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict]: """Get sorted data by sort_key and order, using cache if possible""" async with self._lock: - if (sort_key, order) == self._last_sort: + cache_key = (sort_key, order, seed) + if cache_key == self._last_sort: return self._last_sorted_data start_time = time.perf_counter() - sorted_data = self._sort_data(self.raw_data, sort_key, order) - self._last_sort = (sort_key, order) + sorted_data = self._sort_data(self.raw_data, sort_key, order, seed) + self._last_sort = cache_key self._last_sorted_data = sorted_data duration = time.perf_counter() - start_time @@ -313,8 +322,8 @@ class ModelCache: self.name_display_mode = normalized if self._last_sort[0] == 'name': - sort_key, order = self._last_sort - self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order) + sort_key, order, seed = self._last_sort + self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed) async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool: """Update preview_url for a specific model in all cached data diff --git a/py/services/model_query.py b/py/services/model_query.py index 73386cc4..dabe88e0 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -85,6 +85,7 @@ class SortParams: key: str order: str + seed: Optional[str] = None @dataclass(frozen=True) @@ -116,7 +117,7 @@ class ModelCacheRepository: async def fetch_sorted(self, params: SortParams) -> List[Dict[str, Any]]: """Fetch cached data pre-sorted according to ``params``.""" cache = await self.get_cache() - return await cache.get_sorted_data(params.key, params.order) + return await cache.get_sorted_data(params.key, params.order, params.seed) @staticmethod def parse_sort(sort_by: str) -> SortParams: @@ -132,10 +133,17 @@ class ModelCacheRepository: sort_key = sort_by.strip().lower() or "name" order = "asc" - if order not in ("asc", "desc"): + seed = None + if sort_key == "random": + # Random sort: the portion after ':' is the shuffle seed. + # A stable seed keeps paginated requests consistent; order is + # meaningless for a random shuffle. + seed = order if order and order not in ("asc", "desc") else None + order = "asc" + elif order not in ("asc", "desc"): order = "asc" - return SortParams(key=sort_key, order=order) + return SortParams(key=sort_key, order=order, seed=seed) class ModelFilterSet: diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 197b4cad..a3425413 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1752,7 +1752,7 @@ class ModelScanner: # ---- Conditional resort (only when sort-key fields changed) ---- need_resort = False _last = cache._last_sort - sort_key: Optional[str] = _last[0] if _last != (None, None) else None + sort_key: Optional[str] = _last[0] if _last[0] is not None else None if sort_key == "name": if ( old_model_name != desired_entry.get("model_name", "") diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index 81daf0b1..089eae4a 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -108,10 +108,20 @@ export class PageControls { const sortSelect = document.getElementById('sortSelect'); if (sortSelect) { initSortDropdown(sortSelect); - sortSelect.value = this.pageState.sortBy; + this.applySortToSelect(this.pageState.sortBy); sortSelect.addEventListener('change', async (e) => { - this.pageState.sortBy = e.target.value; - this.saveSortPreference(e.target.value); + let value = e.target.value; + if (value.startsWith('random')) { + // Every pick of Random reshuffles the list: generate a + // fresh seed so the backend keeps a stable order across + // paginated requests. + value = this._randomizeSortValue(); + } + this.pageState.sortBy = value; + this.saveSortPreference(value); + // Reset the seeded Random option when switching away from + // Random, or re-apply the fresh seed when picking it again. + this.applySortToSelect(value); await this.resetAndReload(); }); } @@ -312,6 +322,44 @@ export class PageControls { } } + /** + * Apply a sort value to the native sort in sync so its value matches the persisted sort + * string and the dropdown shows the selected label. + * @returns {string} The new sort value, e.g. "random:abc123xyz" + */ + _randomizeSortValue() { + const seed = Math.random().toString(36).slice(2, 12); + const value = `random:${seed}`; + const sortSelect = document.getElementById('sortSelect'); + if (sortSelect) { + const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]'); + if (randomOpt) { + randomOpt.value = value; + } + sortSelect.value = value; + } + return value; + } + /** * Load sort preference from storage */ @@ -326,10 +374,7 @@ export class PageControls { // Handle legacy format conversion const convertedSort = this.convertLegacySortFormat(savedSort); this.pageState.sortBy = convertedSort; - const sortSelect = document.getElementById('sortSelect'); - if (sortSelect) { - sortSelect.value = convertedSort; - } + this.applySortToSelect(convertedSort); } } @@ -523,9 +568,9 @@ export class PageControls { this.pageState.sortBy = restoredSort; this.saveSortPreference(restoredSort); this._removeVlmSortOption(); + this.applySortToSelect(restoredSort); const sortSelect = document.getElementById('sortSelect'); if (sortSelect) { - sortSelect.value = restoredSort; sortSelect.disabled = false; } } @@ -575,10 +620,7 @@ export class PageControls { const savedGroupedSort = getStorageItem(groupedKey); if (savedGroupedSort) { this.pageState.sortBy = savedGroupedSort; - const sortSelect = document.getElementById('sortSelect'); - if (sortSelect) { - sortSelect.value = savedGroupedSort; - } + this.applySortToSelect(savedGroupedSort); } } else { // Leaving group mode: persist current sort for next time, restore non-group sort @@ -586,10 +628,7 @@ export class PageControls { const savedNormalSort = getStorageItem(`${this.pageType}_sort`); if (savedNormalSort) { this.pageState.sortBy = savedNormalSort; - const sortSelect = document.getElementById('sortSelect'); - if (sortSelect) { - sortSelect.value = savedNormalSort; - } + this.applySortToSelect(savedNormalSort); } } } @@ -874,7 +913,7 @@ export class PageControls { } if (sortSelect) { - sortSelect.value = this.pageState.sortBy; + this.applySortToSelect(this.pageState.sortBy); } if (searchInput) { searchInput.value = this.pageState.filters?.search || ''; diff --git a/static/js/components/controls/SortDropdown.js b/static/js/components/controls/SortDropdown.js index 31b15f4b..ee90b592 100644 --- a/static/js/components/controls/SortDropdown.js +++ b/static/js/components/controls/SortDropdown.js @@ -96,7 +96,16 @@ export function initSortDropdown(select) { }; const choose = (value) => { - if (select.value === value) return; + if (select.value === value) { + // Re-picking the already-selected option is normally a no-op, + // matching native + + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + +
+ `; +} + +async function createControls() { + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js'); + return { stateModule, controls: new LorasControls() }; +} + +describe('Random sort option', () => { + it('generates a seeded sort value when Random is picked', async () => { + renderControlsDom('loras'); + const { controls } = await createControls(); + const sortSelect = document.getElementById('sortSelect'); + const randomOpt = sortSelect.querySelector('option[value="random"]'); + + sortSelect.value = 'random'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + + expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/); + expect(localStorage.getItem('lora_manager_loras_sort')).toBe(controls.pageState.sortBy); + expect(randomOpt.value).toBe(controls.pageState.sortBy); + expect(sortSelect.value).toBe(controls.pageState.sortBy); + expect(resetAndReloadMock).toHaveBeenCalled(); + }); + + it('reshuffles with a fresh seed every time Random is picked again', async () => { + renderControlsDom('loras'); + const { controls } = await createControls(); + const sortSelect = document.getElementById('sortSelect'); + const randomOpt = sortSelect.querySelector('option[value="random"]'); + + // First pick + sortSelect.value = 'random'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + const firstSeed = controls.pageState.sortBy; + + // Second pick: the option now carries the seeded value, like a menu click + sortSelect.value = randomOpt.value; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + + expect(controls.pageState.sortBy).toMatch(/^random:[a-z0-9]+$/); + expect(controls.pageState.sortBy).not.toBe(firstSeed); + }); + + it('restores a persisted seeded random sort on load', async () => { + renderControlsDom('loras'); + const savedSort = 'random:persistedseed'; + localStorage.setItem('lora_manager_loras_sort', savedSort); + + const { controls } = await createControls(); + const sortSelect = document.getElementById('sortSelect'); + + expect(controls.pageState.sortBy).toBe(savedSort); + expect(sortSelect.value).toBe(savedSort); + expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull(); + }); + + it('applies a non-random sort back to the plain random option', async () => { + renderControlsDom('loras'); + const { controls } = await createControls(); + const sortSelect = document.getElementById('sortSelect'); + const randomOpt = sortSelect.querySelector('option[value="random"]'); + + // Seed a random sort, then switch to a normal sort + sortSelect.value = 'random'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + controls.applySortToSelect('name:desc'); + + expect(sortSelect.value).toBe('name:desc'); + expect(randomOpt.value).toBe('random'); + }); + + it('resets the seeded option when switching away from Random via the dropdown change handler', async () => { + renderControlsDom('loras'); + const { controls } = await createControls(); + const sortSelect = document.getElementById('sortSelect'); + const randomOpt = sortSelect.querySelector('option[value="random"]'); + + // Pick Random: the option is now seeded + sortSelect.value = 'random'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/); + + // Switch to a non-random sort through the change handler (as a menu + // click does); the option must go back to the plain "random" value + sortSelect.value = 'name:desc'; + sortSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + + expect(controls.pageState.sortBy).toBe('name:desc'); + expect(sortSelect.value).toBe('name:desc'); + expect(randomOpt.value).toBe('random'); + }); +}); diff --git a/tests/frontend/components/sortDropdown.menuSync.test.js b/tests/frontend/components/sortDropdown.menuSync.test.js new file mode 100644 index 00000000..26fbfa3b --- /dev/null +++ b/tests/frontend/components/sortDropdown.menuSync.test.js @@ -0,0 +1,68 @@ +import { describe, it, beforeEach, expect } from 'vitest'; +import { initSortDropdown } from '../../../static/js/components/controls/SortDropdown.js'; + +function renderSortDropdownDom() { + document.body.innerHTML = ` +
+ + +
+
+ `; + return { + select: document.getElementById('sortSelect'), + menu: document.querySelector('.sort-dropdown-menu'), + label: document.querySelector('.sort-trigger__label'), + }; +} + +describe('SortDropdown menu sync', () => { + let select; + let menu; + let label; + + beforeEach(() => { + ({ select, menu, label } = renderSortDropdownDom()); + initSortDropdown(select); + }); + + it('rebuilds the menu and highlights the selected item when an option value attribute changes', async () => { + // The seeded Random option gets a new value each time it is picked. + // The select's value getter follows the selected option's new value. + const randomOpt = select.querySelector('option[value="random"]'); + randomOpt.value = 'random:abc123'; + await Promise.resolve(); + + const items = [...menu.querySelectorAll('.sort-option')]; + expect(items.map((el) => el.dataset.value)).toContain('random:abc123'); + const seededItem = items.find((el) => el.dataset.value === 'random:abc123'); + expect(seededItem.classList.contains('is-selected')).toBe(true); + expect(label.textContent).toBe('Randomize (shuffle)'); + }); + + it('drops the stale seeded item and re-selects the plain random item when the option is reset', async () => { + const randomOpt = select.querySelector('option[value="random"]'); + randomOpt.value = 'random:abc123'; + await Promise.resolve(); + + // The rebuild must have happened: the seeded item is in the menu + const seededItems = [...menu.querySelectorAll('.sort-option')] + .filter((el) => el.dataset.value === 'random:abc123'); + expect(seededItems).toHaveLength(1); + + // PageControls resets the option to "random" when switching away + randomOpt.value = 'random'; + await Promise.resolve(); + + const items = [...menu.querySelectorAll('.sort-option')]; + expect(items.map((el) => el.dataset.value)).not.toContain('random:abc123'); + const randomItem = items.find((el) => el.dataset.value === 'random'); + expect(randomItem.classList.contains('is-selected')).toBe(true); + }); +}); diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index 24806d12..25ac6d2d 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -183,7 +183,7 @@ class FakeCache: def __init__(self, items): self.items = list(items) - async def get_sorted_data(self, sort_key, order): + async def get_sorted_data(self, sort_key, order, seed=None): if sort_key == "name": data = sorted(self.items, key=lambda x: x["model_name"].lower()) if order == "desc": diff --git a/tests/services/test_model_scanner.py b/tests/services/test_model_scanner.py index 58abda71..90298c33 100644 --- a/tests/services/test_model_scanner.py +++ b/tests/services/test_model_scanner.py @@ -884,7 +884,7 @@ async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch raw_data=[dict(entry)], folders=[], name_display_mode="model_name" ) await scanner._cache.resort() - scanner._cache._last_sort = ("name", "asc") # name sort is active + scanner._cache._last_sort = ("name", "asc", None) # name sort is active scanner._tags_count = {"alpha": 1} scanner._hash_index.add_entry("abc123", "/m/a.safetensors") @@ -935,7 +935,7 @@ async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypat raw_data=[dict(entry)], folders=[], name_display_mode="model_name" ) await scanner._cache.resort() - scanner._cache._last_sort = ("name", "asc") + scanner._cache._last_sort = ("name", "asc", None) scanner._tags_count = {"alpha": 1} scanner._hash_index.add_entry("abc123", "/m/a.safetensors") diff --git a/tests/services/test_model_sort.py b/tests/services/test_model_sort.py new file mode 100644 index 00000000..790a3c97 --- /dev/null +++ b/tests/services/test_model_sort.py @@ -0,0 +1,97 @@ +"""Tests for sort parsing and the seeded random sort mode.""" + +import asyncio + +import pytest + +from py.services.model_cache import ModelCache +from py.services.model_query import ModelCacheRepository, SortParams + + +def _make_cache(items): + return ModelCache( + raw_data=[ + { + "file_path": f"/models/{name}.safetensors", + "file_name": f"{name}.safetensors", + "model_name": name, + "folder": "", + "size": 100, + "modified": 0.0, + } + for name in items + ], + folders=[], + ) + + +class TestParseSort: + def test_random_with_seed(self): + params = ModelCacheRepository.parse_sort("random:abc123") + assert params == SortParams(key="random", order="asc", seed="abc123") + + def test_random_without_seed(self): + params = ModelCacheRepository.parse_sort("random") + assert params == SortParams(key="random", order="asc", seed=None) + + def test_random_empty_seed_falls_back_to_none(self): + params = ModelCacheRepository.parse_sort("random:") + assert params.seed is None + + def test_regular_sorts_unaffected(self): + params = ModelCacheRepository.parse_sort("name:desc") + assert params == SortParams(key="name", order="desc", seed=None) + + +class TestRandomShuffle: + @pytest.mark.asyncio + async def test_same_seed_yields_same_order(self): + cache = _make_cache(["a", "b", "c", "d", "e"]) + await asyncio.sleep(0) # allow background resort task to run + + first = await cache.get_sorted_data("random", "asc", "seed1") + second = await cache.get_sorted_data("random", "asc", "seed1") + + assert [item["model_name"] for item in first] == [ + item["model_name"] for item in second + ] + + @pytest.mark.asyncio + async def test_different_seeds_yield_different_orders(self): + cache = _make_cache([f"m{i}" for i in range(20)]) + await asyncio.sleep(0) + + first = await cache.get_sorted_data("random", "asc", "seed-a") + second = await cache.get_sorted_data("random", "asc", "seed-b") + + assert [item["model_name"] for item in first] != [ + item["model_name"] for item in second + ] + + @pytest.mark.asyncio + async def test_shuffle_is_a_permutation(self): + cache = _make_cache(["a", "b", "c", "d", "e"]) + await asyncio.sleep(0) + + shuffled = await cache.get_sorted_data("random", "asc", "seed") + + assert sorted(item["model_name"] for item in shuffled) == [ + "a", + "b", + "c", + "d", + "e", + ] + assert len({item["file_path"] for item in shuffled}) == 5 + + @pytest.mark.asyncio + async def test_missing_seed_is_stable(self): + cache = _make_cache(["a", "b", "c", "d", "e"]) + await asyncio.sleep(0) + + first = await cache.get_sorted_data("random", "asc") + second = await cache.get_sorted_data("random", "asc") + + assert [item["model_name"] for item in first] == [ + item["model_name"] for item in second + ]