diff --git a/docs/TEMPLATE_RANKING.md b/docs/TEMPLATE_RANKING.md new file mode 100644 index 0000000000..a52c8fc61b --- /dev/null +++ b/docs/TEMPLATE_RANKING.md @@ -0,0 +1,66 @@ +# Template Ranking System + +Usage-based ordering for workflow templates with position bias normalization. + +Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search). + +## Sort Modes + +| Mode | Formula | Description | +| -------------- | ------------------------------------------------ | ---------------------- | +| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation | +| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven | +| `newest` | Date sort | Existing | +| `alphabetical` | Name sort | Existing | + +Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1. + +## Data Files + +**Usage scores** (generated from Mixpanel): + +```json +// In templates/index.json, add to any template: +{ + "name": "some_template", + "usage": 1000, + ... +} +``` + +**Search rank** (set per-template in workflow_templates repo): + +```json +// In templates/index.json, add to any template: +{ + "name": "some_template", + "searchRank": 8, // Scale 1-10, default 5 + ... +} +``` + +| searchRank | Effect | +| ---------- | ---------------------------- | +| 1-4 | Demote (bury in results) | +| 5 | Neutral (default if not set) | +| 6-10 | Promote (boost in results) | + +## Position Bias Correction + +Raw usage reflects true preference AND UI position bias. We use linear interpolation: + +``` +correction = 1 + (position - 1) / (maxPosition - 1) +normalizedUsage = rawUsage × correction +``` + +| Position | Boost | +| -------- | ----- | +| 1 | 1.0× | +| 50 | 1.28× | +| 100 | 1.57× | +| 175 | 2.0× | + +Templates buried at the bottom get up to 2× boost to compensate for reduced visibility. + +--- diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 5f96fe4c26..7c76e971d8 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -175,6 +175,7 @@ { sessionStartTime.value = Date.now() }) +const systemStatsStore = useSystemStatsStore() + +const distributions = computed(() => { + // eslint-disable-next-line no-undef + switch (__DISTRIBUTION__) { + case 'cloud': + return [TemplateIncludeOnDistributionEnum.Cloud] + case 'localhost': + return [TemplateIncludeOnDistributionEnum.Local] + case 'desktop': + default: + if (systemStatsStore.systemStats?.system.os === 'darwin') { + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Mac + ] + } + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Windows + ] + } +}) + // Wrap onClose to track session end const onClose = () => { if (isCloud) { @@ -511,6 +538,9 @@ const allTemplates = computed(() => { return workflowTemplatesStore.enhancedTemplates }) +// Navigation +const selectedNavItem = ref('all') + // Filter templates based on selected navigation item const navigationFilteredTemplates = computed(() => { if (!selectedNavItem.value) { @@ -536,6 +566,36 @@ const { resetFilters } = useTemplateFiltering(navigationFilteredTemplates) +/** + * Coordinates state between the selected navigation item and the sort order to + * create deterministic, predictable behavior. + * @param source The origin of the change ('nav' or 'sort'). + */ +const coordinateNavAndSort = (source: 'nav' | 'sort') => { + const isPopularNav = selectedNavItem.value === 'popular' + const isPopularSort = sortBy.value === 'popular' + + if (source === 'nav') { + if (isPopularNav && !isPopularSort) { + // When navigating to 'Popular' category, automatically set sort to 'Popular'. + sortBy.value = 'popular' + } else if (!isPopularNav && isPopularSort) { + // When navigating away from 'Popular' category while sort is 'Popular', reset sort to default. + sortBy.value = 'default' + } + } else if (source === 'sort') { + // When sort is changed away from 'Popular' while in the 'Popular' category, + // reset the category to 'All Templates' to avoid a confusing state. + if (isPopularNav && !isPopularSort) { + selectedNavItem.value = 'all' + } + } +} + +// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator. +watch(selectedNavItem, () => coordinateNavAndSort('nav')) +watch(sortBy, () => coordinateNavAndSort('sort')) + // Convert between string array and object array for MultiSelect component const selectedModelObjects = computed({ get() { @@ -578,9 +638,6 @@ const cardRefs = ref([]) // Force re-render key for templates when sorting changes const templateListKey = ref(0) -// Navigation -const selectedNavItem = ref('all') - // Search text for model filter const modelSearchText = ref('') @@ -645,11 +702,19 @@ const runsOnFilterLabel = computed(() => { // Sort options const sortOptions = computed(() => [ - { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.default', 'Default'), value: 'default' }, + { + name: t('templateWorkflows.sort.recommended', 'Recommended'), + value: 'recommended' + }, + { + name: t('templateWorkflows.sort.popular', 'Popular'), + value: 'popular' + }, + { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'), value: 'vram-low-to-high' @@ -750,7 +815,7 @@ const pageTitle = computed(() => { // Initialize templates loading with useAsyncState const { isLoading } = useAsyncState( async () => { - // Run both operations in parallel for better performance + // Run all operations in parallel for better performance await Promise.all([ loadTemplates(), workflowTemplatesStore.loadWorkflowTemplates() @@ -763,6 +828,14 @@ const { isLoading } = useAsyncState( } ) +const isTemplateVisibleOnDistribution = (template: TemplateInfo) => { + return (template.includeOnDistributions?.length ?? 0) > 0 + ? distributions.value.some((d) => + template.includeOnDistributions?.includes(d) + ) + : true +} + onBeforeUnmount(() => { cardRefs.value = [] // Release DOM refs }) diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index f447f230b1..fdb892e07b 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -6,12 +6,14 @@ import type { Ref } from 'vue' import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' import type { TemplateInfo } from '@/platform/workflow/templates/types/template' +import { useTemplateRankingStore } from '@/stores/templateRankingStore' import { debounce } from 'es-toolkit/compat' export function useTemplateFiltering( templates: Ref | TemplateInfo[] ) { const settingStore = useSettingStore() + const rankingStore = useTemplateRankingStore() const searchQuery = ref('') const selectedModels = ref( @@ -25,6 +27,8 @@ export function useTemplateFiltering( ) const sortBy = ref< | 'default' + | 'recommended' + | 'popular' | 'alphabetical' | 'newest' | 'vram-low-to-high' @@ -151,10 +155,42 @@ export function useTemplateFiltering( return Number.POSITIVE_INFINITY } + watch( + filteredByRunsOn, + (templates) => { + rankingStore.largestUsageScore = Math.max( + ...templates.map((t) => t.usage || 0) + ) + }, + { immediate: true } + ) + const sortedTemplates = computed(() => { const templates = [...filteredByRunsOn.value] switch (sortBy.value) { + case 'recommended': + // Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2 + return templates.sort((a, b) => { + const scoreA = rankingStore.computeDefaultScore( + a.date, + a.searchRank, + a.usage + ) + const scoreB = rankingStore.computeDefaultScore( + b.date, + b.searchRank, + b.usage + ) + return scoreB - scoreA + }) + case 'popular': + // User-driven: usage × 0.9 + freshness × 0.1 + return templates.sort((a, b) => { + const scoreA = rankingStore.computePopularScore(a.date, a.usage) + const scoreB = rankingStore.computePopularScore(b.date, b.usage) + return scoreB - scoreA + }) case 'alphabetical': return templates.sort((a, b) => { const nameA = a.title || a.name || '' @@ -184,7 +220,7 @@ export function useTemplateFiltering( return vramA - vramB }) case 'model-size-low-to-high': - return templates.sort((a: any, b: any) => { + return templates.sort((a, b) => { const sizeA = typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY const sizeB = @@ -194,7 +230,6 @@ export function useTemplateFiltering( }) case 'default': default: - // Keep original order (default order) return templates } }) @@ -206,7 +241,7 @@ export function useTemplateFiltering( selectedModels.value = [] selectedUseCases.value = [] selectedRunsOn.value = [] - sortBy.value = 'newest' + sortBy.value = 'default' } const removeModelFilter = (model: string) => { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index af70861a32..44f37cd7c1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -868,7 +868,7 @@ "noResultsHint": "Try adjusting your search or filters", "allTemplates": "All Templates", "modelFilter": "Model Filter", - "useCaseFilter": "Use Case", + "useCaseFilter": "Tasks", "licenseFilter": "License", "modelsSelected": "{count} Models", "useCasesSelected": "{count} Use Cases", @@ -877,6 +877,7 @@ "resultsCount": "Showing {count} of {total} templates", "sort": { "recommended": "Recommended", + "popular": "Popular", "alphabetical": "A → Z", "newest": "Newest", "searchPlaceholder": "Search...", diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 85032dcdf4..2950db2106 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [ id: 'Comfy.Templates.SortBy', name: 'Template library - Sort preference', type: 'hidden', - defaultValue: 'newest' + defaultValue: 'default' }, /** diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index a707069a48..348849bb1b 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -197,6 +197,8 @@ export interface TemplateFilterMetadata { selected_runs_on: string[] sort_by: | 'default' + | 'recommended' + | 'popular' | 'alphabetical' | 'newest' | 'vram-low-to-high' diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index 9759c029da..8ce653d4da 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -6,7 +6,7 @@ import { i18n, st } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { api } from '@/scripts/api' import type { NavGroupData, NavItemData } from '@/types/navTypes' -import { getCategoryIcon } from '@/utils/categoryIcons' +import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil' import { normalizeI18nKey } from '@/utils/formatUtil' import type { @@ -276,9 +276,18 @@ export const useWorkflowTemplatesStore = defineStore( return enhancedTemplates.value } - if (categoryId === 'basics') { + if (categoryId.startsWith('basics-')) { // Filter for templates from categories marked as essential - return enhancedTemplates.value.filter((t) => t.isEssential) + return enhancedTemplates.value.filter( + (t) => + t.isEssential && + t.category?.toLowerCase().replace(/\s+/g, '-') === + categoryId.replace('basics-', '') + ) + } + + if (categoryId === 'popular') { + return enhancedTemplates.value } if (categoryId === 'partner-nodes') { @@ -333,20 +342,34 @@ export const useWorkflowTemplatesStore = defineStore( icon: getCategoryIcon('all') }) - // 2. Basics (isEssential categories) - always second if it exists - const essentialCat = coreTemplates.value.find( + // 1.5. Popular categories + + items.push({ + id: 'popular', + label: st('templateWorkflows.category.Popular', 'Popular'), + icon: 'icon-[lucide--flame]' + }) + + // 2. Basics (isEssential categories) - always beneath All Templates if they exist + const essentialCats = coreTemplates.value.filter( (cat) => cat.isEssential && cat.templates.length > 0 ) - if (essentialCat) { - const categoryTitle = essentialCat.title ?? 'Getting Started' - items.push({ - id: 'basics', - label: st( - `templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`, - categoryTitle - ), - icon: 'icon-[lucide--graduation-cap]' + if (essentialCats.length > 0) { + essentialCats.forEach((essentialCat) => { + const categoryIcon = essentialCat.icon + const categoryTitle = essentialCat.title ?? 'Getting Started' + const categoryId = generateCategoryId('basics', essentialCat.title) + items.push({ + id: categoryId, + label: st( + `templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`, + categoryTitle + ), + icon: + categoryIcon || + getCategoryIcon(essentialCat.type || 'getting-started') + }) }) } @@ -375,7 +398,7 @@ export const useWorkflowTemplatesStore = defineStore( const group = categoryGroups.get(categoryGroup)! // Generate unique ID for this category - const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}` + const categoryId = generateCategoryId(categoryGroup, category.title) // Store the filter mapping categoryFilters.value.set(categoryId, { diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts index 25a209e854..1a9d839f83 100644 --- a/src/platform/workflow/templates/types/template.ts +++ b/src/platform/workflow/templates/types/template.ts @@ -32,6 +32,29 @@ export interface TemplateInfo { * Templates with this field will be hidden on local installations temporarily. */ requiresCustomNodes?: string[] + /** + * Manual ranking boost/demotion for "Recommended" sort. Scale 1-10, default 5. + * Higher values promote the template, lower values demote it. + */ + searchRank?: number + /** + * Usage score based on real world usage statistics. + * Used for popular templates sort and for "Recommended" sort boost. + */ + usage?: number + /** + * Manage template's visibility across different distributions by specifying which distributions it should be included on. + * If not specified, the template will be included on all distributions. + */ + includeOnDistributions?: TemplateIncludeOnDistributionEnum[] +} + +export enum TemplateIncludeOnDistributionEnum { + Cloud = 'cloud', + Local = 'local', + Desktop = 'desktop', + Mac = 'mac', + Windows = 'windows' } export interface WorkflowTemplates { diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 83c9890a8b..b84d4457ab 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -526,6 +526,8 @@ const zSettings = z.object({ 'Comfy.Templates.SelectedRunsOn': z.array(z.string()), 'Comfy.Templates.SortBy': z.enum([ 'default', + 'recommended', + 'popular', 'alphabetical', 'newest', 'vram-low-to-high', diff --git a/src/stores/templateRankingStore.ts b/src/stores/templateRankingStore.ts new file mode 100644 index 0000000000..a0dc68d8eb --- /dev/null +++ b/src/stores/templateRankingStore.ts @@ -0,0 +1,66 @@ +/** + * Store for template ranking scores. + * Loads pre-computed usage scores from static JSON. + * Internal ranks come from template.searchRank in index.json. + * See docs/TEMPLATE_RANKING.md for details. + */ + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useTemplateRankingStore = defineStore('templateRanking', () => { + const largestUsageScore = ref() + + const normalizeUsageScore = (usage: number): number => { + return usage / (largestUsageScore.value ?? usage) + } + + /** + * Compute freshness score based on template date. + * Returns 1.0 for brand new, decays to 0.1 over ~6 months. + */ + const computeFreshness = (dateStr: string | undefined): number => { + if (!dateStr) return 0.5 // Default for templates without dates + + const date = new Date(dateStr) + if (isNaN(date.getTime())) return 0.5 + + const daysSinceAdded = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24) + return Math.max(0.1, 1.0 / (1 + daysSinceAdded / 90)) + } + + /** + * Compute composite score for "default" sort. + * Formula: usage × 0.5 + internal × 0.3 + freshness × 0.2 + */ + const computeDefaultScore = ( + dateStr: string | undefined, + searchRank: number | undefined, + usage: number = 0 + ): number => { + const internal = (searchRank ?? 5) / 10 // Normalize 1-10 to 0-1 + const freshness = computeFreshness(dateStr) + + return normalizeUsageScore(usage) * 0.5 + internal * 0.3 + freshness * 0.2 + } + + /** + * Compute composite score for "popular" sort. + * Formula: usage × 0.9 + freshness × 0.1 + */ + const computePopularScore = ( + dateStr: string | undefined, + usage: number = 0 + ): number => { + const freshness = computeFreshness(dateStr) + + return normalizeUsageScore(usage) * 0.9 + freshness * 0.1 + } + + return { + largestUsageScore, + computeFreshness, + computeDefaultScore, + computePopularScore + } +}) diff --git a/src/utils/categoryIcons.ts b/src/utils/categoryUtil.ts similarity index 85% rename from src/utils/categoryIcons.ts rename to src/utils/categoryUtil.ts index 9c202df07f..e2abb56e17 100644 --- a/src/utils/categoryIcons.ts +++ b/src/utils/categoryUtil.ts @@ -50,3 +50,13 @@ export const getCategoryIcon = (categoryId: string): string => { // Return mapped icon or fallback to folder return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]' } + +/** + * Generates a unique category ID from a category group and title + */ +export function generateCategoryId( + categoryGroup: string, + categoryTitle: string +) { + return `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${categoryTitle.toLowerCase().replace(/\s+/g, '-')}` +} diff --git a/tests-ui/stores/templateRankingStore.test.ts b/tests-ui/stores/templateRankingStore.test.ts new file mode 100644 index 0000000000..cb3a9539dd --- /dev/null +++ b/tests-ui/stores/templateRankingStore.test.ts @@ -0,0 +1,135 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useTemplateRankingStore } from '@/stores/templateRankingStore' + +// Mock axios +vi.mock('axios', () => ({ + default: { + get: vi.fn() + } +})) + +describe('templateRankingStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe('computeFreshness', () => { + it('returns 1.0 for brand new template (today)', () => { + const store = useTemplateRankingStore() + const today = new Date().toISOString().split('T')[0] + const freshness = store.computeFreshness(today) + expect(freshness).toBeCloseTo(1.0, 1) + }) + + it('returns ~0.5 for 90-day old template', () => { + const store = useTemplateRankingStore() + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0] + const freshness = store.computeFreshness(ninetyDaysAgo) + expect(freshness).toBeCloseTo(0.5, 1) + }) + + it('returns 0.1 minimum for very old template', () => { + const store = useTemplateRankingStore() + const freshness = store.computeFreshness('2020-01-01') + expect(freshness).toBe(0.1) + }) + + it('returns 0.5 for undefined date', () => { + const store = useTemplateRankingStore() + expect(store.computeFreshness(undefined)).toBe(0.5) + }) + + it('returns 0.5 for invalid date', () => { + const store = useTemplateRankingStore() + expect(store.computeFreshness('not-a-date')).toBe(0.5) + }) + }) + + describe('computeDefaultScore', () => { + it('uses default searchRank of 5 when not provided', () => { + const store = useTemplateRankingStore() + // Set largestUsageScore to avoid NaN when usage is 0 + store.largestUsageScore = 100 + const score = store.computeDefaultScore('2024-01-01', undefined, 0) + // With no usage score loaded, usage = 0 + // internal = 5/10 = 0.5, freshness ~0.1 (old date) + // score = 0 * 0.5 + 0.5 * 0.3 + 0.1 * 0.2 = 0.15 + 0.02 = 0.17 + expect(score).toBeCloseTo(0.17, 1) + }) + + it('high searchRank (10) boosts score', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const lowRank = store.computeDefaultScore('2024-01-01', 1, 0) + const highRank = store.computeDefaultScore('2024-01-01', 10, 0) + expect(highRank).toBeGreaterThan(lowRank) + }) + + it('low searchRank (1) demotes score', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const neutral = store.computeDefaultScore('2024-01-01', 5, 0) + const demoted = store.computeDefaultScore('2024-01-01', 1, 0) + expect(demoted).toBeLessThan(neutral) + }) + + it('searchRank difference is significant', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const rank1 = store.computeDefaultScore('2024-01-01', 1, 0) + const rank10 = store.computeDefaultScore('2024-01-01', 10, 0) + // Difference should be 0.9 * 0.3 = 0.27 (30% weight, 0.9 range) + expect(rank10 - rank1).toBeCloseTo(0.27, 2) + }) + }) + + describe('computePopularScore', () => { + it('does not use searchRank', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + // Popular score ignores searchRank - just usage + freshness + const score1 = store.computePopularScore('2024-01-01', 0) + const score2 = store.computePopularScore('2024-01-01', 0) + expect(score1).toBe(score2) + }) + + it('newer templates score higher', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const today = new Date().toISOString().split('T')[0] + const oldScore = store.computePopularScore('2020-01-01', 0) + const newScore = store.computePopularScore(today, 0) + expect(newScore).toBeGreaterThan(oldScore) + }) + }) + + describe('searchRank edge cases', () => { + it('handles searchRank of 0 (should still work, treated as very low)', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const score = store.computeDefaultScore('2024-01-01', 0, 0) + expect(score).toBeGreaterThanOrEqual(0) + }) + + it('handles searchRank above 10 (clamping not enforced, but works)', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const rank10 = store.computeDefaultScore('2024-01-01', 10, 0) + const rank15 = store.computeDefaultScore('2024-01-01', 15, 0) + expect(rank15).toBeGreaterThan(rank10) + }) + + it('handles negative searchRank', () => { + const store = useTemplateRankingStore() + store.largestUsageScore = 100 + const score = store.computeDefaultScore('2024-01-01', -5, 0) + // Should still compute, just negative contribution from searchRank + expect(typeof score).toBe('number') + }) + }) +}) diff --git a/tests-ui/tests/composables/useTemplateFiltering.test.ts b/tests-ui/tests/composables/useTemplateFiltering.test.ts index bb2bce269c..5f30e5ec1b 100644 --- a/tests-ui/tests/composables/useTemplateFiltering.test.ts +++ b/tests-ui/tests/composables/useTemplateFiltering.test.ts @@ -1,3 +1,4 @@ +import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' @@ -19,10 +20,22 @@ const defaultSettingStore = { set: vi.fn().mockResolvedValue(undefined) } +const defaultRankingStore = { + computeDefaultScore: vi.fn(() => 0), + computePopularScore: vi.fn(() => 0), + getUsageScore: vi.fn(() => 0), + computeFreshness: vi.fn(() => 0.5), + isLoaded: { value: false } +} + vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: vi.fn(() => defaultSettingStore) })) +vi.mock('@/stores/templateRankingStore', () => ({ + useTemplateRankingStore: vi.fn(() => defaultRankingStore) +})) + vi.mock('@/platform/telemetry', () => ({ useTelemetry: vi.fn(() => ({ trackTemplateFilterChanged: vi.fn() @@ -34,6 +47,7 @@ const { useTemplateFiltering } = describe('useTemplateFiltering', () => { beforeEach(() => { + setActivePinia(createPinia()) vi.clearAllMocks() })