diff --git a/templates/other.html b/templates/other.html
new file mode 100644
index 00000000..46f25668
--- /dev/null
+++ b/templates/other.html
@@ -0,0 +1,74 @@
+{% extends "base.html" %}
+
+{% block title %}{{ t('other.title') }}{% endblock %}
+{% block page_id %}other{% endblock %}
+
+{% block init_title %}{{ t('initialization.other.title') }}{% endblock %}
+{% block init_message %}{{ t('initialization.other.message') }}{% endblock %}
+{% block init_check_url %}/api/other/list?page=1&page_size=1{% endblock %}
+
+{% block additional_components %}
+
+
+{% endblock %}
+
+{% block content %}
+
+ {% include 'components/controls.html' %}
+ {% include 'components/breadcrumb.html' %}
+
+ {% include 'components/duplicates_banner.html' %}
+ {% include 'components/folder_sidebar.html' %}
+
+
+
+
+
+{% endblock %}
+
+{% block overlay %}
+
+{% endblock %}
+
+{% block main_script %}
+
+{% endblock %}
diff --git a/tests/frontend/api/otherApi.test.js b/tests/frontend/api/otherApi.test.js
new file mode 100644
index 00000000..c8293bf4
--- /dev/null
+++ b/tests/frontend/api/otherApi.test.js
@@ -0,0 +1,85 @@
+import { describe, it, expect } from 'vitest';
+// Import order matters: api modules are circularly dependent
+// (modelApiFactory -> loraApi -> baseModelApi -> modelApiFactory/state).
+// Loading the factory first lets baseModelApi fully evaluate before the
+// client subclasses extend it.
+import { createModelApiClient, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
+import {
+ MODEL_TYPES,
+ MODEL_CONFIG,
+ getApiEndpoints,
+ getCompleteApiConfig,
+ isValidModelType,
+} from '../../../static/js/api/apiConfig.js';
+import { OtherApiClient } from '../../../static/js/api/otherApi.js';
+
+describe('apiConfig - other model type', () => {
+ it('exposes OTHER model type', () => {
+ expect(MODEL_TYPES.OTHER).toBe('other');
+ expect(isValidModelType('other')).toBe(true);
+ });
+
+ it('has a complete MODEL_CONFIG entry', () => {
+ const config = MODEL_CONFIG[MODEL_TYPES.OTHER];
+
+ expect(config).toBeDefined();
+ expect(config.singularName).toBe('other');
+ expect(config.supportsLetterFilter).toBe(false);
+ expect(config.supportsBulkOperations).toBe(true);
+ expect(config.supportsMove).toBe(true);
+ expect(config.templateName).toBe('other.html');
+ });
+
+ it('generates /api/lm/other/* endpoints', () => {
+ const endpoints = getApiEndpoints('other');
+
+ expect(endpoints.list).toBe('/api/lm/other/list');
+ expect(endpoints.delete).toBe('/api/lm/other/delete');
+ expect(endpoints.exclude).toBe('/api/lm/other/exclude');
+ expect(endpoints.unexclude).toBe('/api/lm/other/unexclude');
+ expect(endpoints.rename).toBe('/api/lm/other/rename');
+ expect(endpoints.save).toBe('/api/lm/other/save-metadata');
+ expect(endpoints.bulkDelete).toBe('/api/lm/other/bulk-delete');
+ expect(endpoints.moveModel).toBe('/api/lm/other/move_model');
+ expect(endpoints.moveBulk).toBe('/api/lm/other/move_models_bulk');
+ expect(endpoints.fetchCivitai).toBe('/api/lm/other/fetch-civitai');
+ expect(endpoints.fetchAllCivitai).toBe('/api/lm/other/fetch-all-civitai');
+ expect(endpoints.scan).toBe('/api/lm/other/scan');
+ expect(endpoints.topTags).toBe('/api/lm/other/top-tags');
+ expect(endpoints.baseModels).toBe('/api/lm/other/base-models');
+ expect(endpoints.roots).toBe('/api/lm/other/roots');
+ expect(endpoints.folders).toBe('/api/lm/other/folders');
+ expect(endpoints.duplicates).toBe('/api/lm/other/find-duplicates');
+ expect(endpoints.replacePreview).toBe('/api/lm/other/replace-preview');
+ });
+
+ it('merges other-specific endpoints into the complete config', () => {
+ const config = getCompleteApiConfig('other');
+
+ expect(config.modelType).toBe('other');
+ expect(config.config).toBe(MODEL_CONFIG.other);
+ expect(config.endpoints.specific.metadata).toBe('/api/lm/other/metadata');
+ });
+});
+
+describe('modelApiFactory - other model type', () => {
+ it('creates an OtherApiClient for the other model type', () => {
+ const client = createModelApiClient(MODEL_TYPES.OTHER);
+
+ expect(client).toBeInstanceOf(OtherApiClient);
+ expect(client.modelType).toBe('other');
+ expect(client.apiConfig.endpoints.list).toBe('/api/lm/other/list');
+ });
+
+ it('returns a cached singleton from getModelApiClient', () => {
+ const first = getModelApiClient(MODEL_TYPES.OTHER);
+ const second = getModelApiClient(MODEL_TYPES.OTHER);
+
+ expect(first).toBeInstanceOf(OtherApiClient);
+ expect(second).toBe(first);
+ });
+
+ it('still rejects unsupported model types', () => {
+ expect(() => createModelApiClient('bogus')).toThrow('Unsupported model type: bogus');
+ });
+});
diff --git a/tests/frontend/components/modelCard.subTypeBadge.test.js b/tests/frontend/components/modelCard.subTypeBadge.test.js
new file mode 100644
index 00000000..a1bea917
--- /dev/null
+++ b/tests/frontend/components/modelCard.subTypeBadge.test.js
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const {
+ MODEL_CARD_MODULE,
+ STATE_MODULE,
+ UI_HELPERS_MODULE,
+ I18N_MODULE,
+ API_CONFIG_MODULE,
+ API_FACTORY_MODULE,
+} = vi.hoisted(() => ({
+ MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
+ STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
+ UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
+ I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
+ API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
+ API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
+}));
+
+vi.mock(STATE_MODULE, () => ({
+ state: {
+ settings: {
+ blur_mature_content: false,
+ model_name_display: 'model_name',
+ },
+ global: {
+ settings: {
+ model_name_display: 'model_name',
+ group_by_model: false,
+ display_density: 'default',
+ model_card_footer_action: 'example_images',
+ },
+ },
+ pages: {
+ other: {
+ previewVersions: new Map(),
+ sortBy: 'name',
+ },
+ },
+ bulkMode: false,
+ selectedModels: new Set(),
+ selectedLoras: new Set(),
+ },
+ getCurrentPageState: vi.fn(() => ({
+ sortBy: 'name',
+ previewVersions: new Map(),
+ })),
+}));
+
+vi.mock(UI_HELPERS_MODULE, () => ({
+ showToast: vi.fn(),
+ openCivitai: vi.fn(),
+ openHuggingFace: vi.fn(),
+ copyToClipboard: vi.fn(),
+ copyLoraSyntax: vi.fn(),
+ sendLoraToWorkflow: vi.fn(),
+ sendEmbeddingToWorkflow: vi.fn(),
+ openExampleImagesFolder: vi.fn(),
+ buildLoraSyntax: vi.fn(),
+ sendModelPathToWorkflow: vi.fn(),
+}));
+
+vi.mock(I18N_MODULE, () => ({
+ translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
+}));
+
+vi.mock(API_CONFIG_MODULE, () => ({
+ MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', OTHER: 'other' },
+}));
+
+vi.mock(API_FACTORY_MODULE, () => ({
+ getModelApiClient: vi.fn(() => ({})),
+}));
+
+function createOtherModel(overrides = {}) {
+ return {
+ sha256: 'abc123',
+ file_path: '/models/vae/test_vae.safetensors',
+ model_name: 'Test VAE',
+ file_name: 'test_vae',
+ folder: 'vae',
+ modified: 1234567890,
+ file_size: 1024,
+ notes: '',
+ base_model: '',
+ favorite: false,
+ exclude: false,
+ hf_url: '',
+ update_available: false,
+ skip_metadata_refresh: false,
+ preview_url: '',
+ preview_nsfw_level: 0,
+ tags: [],
+ civitai: {},
+ sub_type: 'vae',
+ ...overrides,
+ };
+}
+
+describe('ModelCard sub-type badges for other model types', () => {
+ let createModelCard;
+
+ beforeEach(async () => {
+ ({ createModelCard } = await import(MODEL_CARD_MODULE));
+ });
+
+ it.each([
+ ['vae', 'VAE', 'VAE'],
+ ['upscaler', 'UPS', 'Upscaler'],
+ ['text_encoder', 'TE', 'Text Encoder'],
+ ['clip_vision', 'CV', 'CLIP Vision'],
+ ['controlnet', 'CN', 'ControlNet'],
+ ])('renders the %s badge abbreviation and tooltip', (subType, abbreviation, displayName) => {
+ const card = createModelCard(createOtherModel({ sub_type: subType }), 'other');
+
+ const badge = card.querySelector('.model-sub-type');
+ expect(badge).not.toBeNull();
+ expect(badge.textContent).toBe(abbreviation);
+
+ const label = card.querySelector('.base-model-label');
+ expect(label.getAttribute('title')).toContain(displayName);
+ });
+
+ it('stores sub_type on the card dataset', () => {
+ const card = createModelCard(createOtherModel({ sub_type: 'text_encoder' }), 'other');
+
+ expect(card.dataset.sub_type).toBe('text_encoder');
+ });
+});
diff --git a/tests/frontend/components/otherFactories.test.js b/tests/frontend/components/otherFactories.test.js
new file mode 100644
index 00000000..39cd0983
--- /dev/null
+++ b/tests/frontend/components/otherFactories.test.js
@@ -0,0 +1,159 @@
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
+
+const {
+ getModelApiClientMock,
+ resetAndReloadMock,
+ showToastMock,
+ sidebarManagerMock,
+ moveManagerMock,
+ showDeleteModalMock,
+ showExcludeModalMock,
+} = vi.hoisted(() => ({
+ getModelApiClientMock: vi.fn(),
+ resetAndReloadMock: vi.fn(async () => {}),
+ showToastMock: vi.fn(),
+ sidebarManagerMock: {
+ setHostPageControls: vi.fn(),
+ initialize: vi.fn(async function () {
+ sidebarManagerMock.isInitialized = true;
+ }),
+ refresh: vi.fn(async () => {}),
+ cleanup: vi.fn(),
+ isInitialized: false,
+ },
+ moveManagerMock: {
+ showMoveModal: vi.fn(),
+ },
+ showDeleteModalMock: vi.fn(),
+ showExcludeModalMock: vi.fn(),
+}));
+
+vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
+ getModelApiClient: getModelApiClientMock,
+ resetAndReload: resetAndReloadMock,
+}));
+
+vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
+ showToast: showToastMock,
+ openCivitaiByMetadata: vi.fn(),
+ isTypingContext: () => false,
+ getNSFWLevelName: vi.fn(() => 'Unknown'),
+ openExampleImagesFolder: vi.fn(),
+}));
+
+vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
+ downloadManager: { showDownloadModal: vi.fn() },
+}));
+
+vi.mock('../../../static/js/components/SidebarManager.js', () => ({
+ sidebarManager: sidebarManagerMock,
+}));
+
+vi.mock('../../../static/js/managers/MoveManager.js', () => ({
+ moveManager: moveManagerMock,
+}));
+
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ showDeleteModal: showDeleteModalMock,
+ showExcludeModal: showExcludeModalMock,
+}));
+
+vi.mock('../../../static/js/components/alphabet/index.js', () => ({
+ createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })),
+}));
+
+vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({
+ performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'Model', records: [] })),
+}));
+
+import { createPageControls } from '../../../static/js/components/controls/index.js';
+import { OtherControls } from '../../../static/js/components/controls/OtherControls.js';
+import { createPageContextMenu } from '../../../static/js/components/ContextMenu/index.js';
+import { OtherContextMenu } from '../../../static/js/components/ContextMenu/OtherContextMenu.js';
+
+describe('createPageControls', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ sessionStorage.clear();
+ document.body.innerHTML = '';
+ document.body.dataset.page = 'other';
+ sidebarManagerMock.isInitialized = false;
+ });
+
+ afterEach(() => {
+ delete window.pageControls;
+ delete window.bulkManager;
+ });
+
+ it('creates OtherControls for the other page type', () => {
+ const controls = createPageControls('other');
+
+ expect(controls).toBeInstanceOf(OtherControls);
+ expect(controls.pageType).toBe('other');
+ // OtherControls registers its API with the base class
+ expect(typeof controls.api.loadMoreModels).toBe('function');
+ expect(typeof controls.api.refreshModels).toBe('function');
+ expect(typeof controls.api.fetchFromCivitai).toBe('function');
+ expect(typeof controls.api.toggleBulkMode).toBe('function');
+ });
+
+ it('returns null for an unknown page type', () => {
+ expect(createPageControls('not-a-page')).toBeNull();
+ });
+});
+
+describe('createPageContextMenu', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ function createMenuWithCard() {
+ const menu = createPageContextMenu('other');
+ const card = document.createElement('div');
+ card.className = 'model-card';
+ card.dataset.filepath = '/models/vae/test.safetensors';
+ document.body.appendChild(card);
+ menu.currentCard = card;
+ return { menu, card };
+ }
+
+ it('creates OtherContextMenu for the other page type', () => {
+ const menu = createPageContextMenu('other');
+
+ expect(menu).toBeInstanceOf(OtherContextMenu);
+ expect(menu.modelType).toBe('other');
+ expect(menu.menu).toBe(document.getElementById('otherContextMenu'));
+ });
+
+ it('returns null for an unknown page type', () => {
+ expect(createPageContextMenu('not-a-page')).toBeNull();
+ });
+
+ it('delegates refresh-metadata to the model API client', () => {
+ const refreshSingleModelMetadata = vi.fn();
+ getModelApiClientMock.mockReturnValue({ refreshSingleModelMetadata });
+ const { menu } = createMenuWithCard();
+
+ menu.handleMenuAction('refresh-metadata');
+
+ expect(refreshSingleModelMetadata).toHaveBeenCalledWith('/models/vae/test.safetensors');
+ });
+
+ it('opens the move modal for the move action', () => {
+ const { menu } = createMenuWithCard();
+
+ menu.handleMenuAction('move');
+
+ expect(moveManagerMock.showMoveModal).toHaveBeenCalledWith('/models/vae/test.safetensors');
+ });
+
+ it('shows the exclude modal for the exclude action', () => {
+ const { menu } = createMenuWithCard();
+
+ menu.handleMenuAction('exclude');
+
+ expect(showExcludeModalMock).toHaveBeenCalledWith('/models/vae/test.safetensors');
+ });
+});
diff --git a/tests/frontend/core/appCore.test.js b/tests/frontend/core/appCore.test.js
index 71cdae9e..d7d54bcd 100644
--- a/tests/frontend/core/appCore.test.js
+++ b/tests/frontend/core/appCore.test.js
@@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.js';
-const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
+const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
describe('AppCore page orchestration', () => {
beforeEach(() => {
diff --git a/tests/frontend/pages/otherPage.test.js b/tests/frontend/pages/otherPage.test.js
new file mode 100644
index 00000000..b39005c5
--- /dev/null
+++ b/tests/frontend/pages/otherPage.test.js
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { renderOtherPage } from '../utils/pageFixtures.js';
+
+const initializeAppMock = vi.fn();
+const initializePageFeaturesMock = vi.fn();
+const createPageControlsMock = vi.fn();
+const confirmDeleteMock = vi.fn();
+const closeDeleteModalMock = vi.fn();
+const confirmExcludeMock = vi.fn();
+const closeExcludeModalMock = vi.fn();
+const duplicatesManagerMock = vi.fn();
+const initActiveFiltersSyncMock = vi.fn();
+
+vi.mock('../../../static/js/core.js', () => ({
+ appCore: {
+ initialize: initializeAppMock,
+ initializePageFeatures: initializePageFeaturesMock,
+ },
+}));
+
+vi.mock('../../../static/js/components/controls/index.js', () => ({
+ createPageControls: createPageControlsMock,
+}));
+
+vi.mock('../../../static/js/utils/modalUtils.js', () => ({
+ confirmDelete: confirmDeleteMock,
+ closeDeleteModal: closeDeleteModalMock,
+ confirmExclude: confirmExcludeMock,
+ closeExcludeModal: closeExcludeModalMock,
+}));
+
+vi.mock('../../../static/js/api/apiConfig.js', () => ({
+ MODEL_TYPES: {
+ OTHER: 'other',
+ },
+}));
+
+vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
+ ModelDuplicatesManager: duplicatesManagerMock,
+}));
+
+vi.mock('../../../static/js/utils/activeFiltersSync.js', () => ({
+ initActiveFiltersSync: initActiveFiltersSyncMock,
+}));
+
+describe('OtherPageManager', () => {
+ let OtherPageManager;
+ let initializeOtherPage;
+ let duplicatesManagerInstance;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ duplicatesManagerInstance = {
+ checkDuplicatesCount: vi.fn(),
+ };
+
+ duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance);
+ createPageControlsMock.mockReturnValue({ destroy: vi.fn() });
+ initializeAppMock.mockResolvedValue(undefined);
+
+ renderOtherPage();
+
+ ({ OtherPageManager, initializeOtherPage } = await import('../../../static/js/other.js'));
+ });
+
+ afterEach(() => {
+ delete window.confirmDelete;
+ delete window.closeDeleteModal;
+ delete window.confirmExclude;
+ delete window.closeExcludeModal;
+ delete window.modelDuplicatesManager;
+ });
+
+ it('wires page controls and exposes modal helpers during construction', () => {
+ const manager = new OtherPageManager();
+
+ expect(createPageControlsMock).toHaveBeenCalledWith('other');
+ expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'other');
+
+ expect(window.confirmDelete).toBe(confirmDeleteMock);
+ expect(window.closeDeleteModal).toBe(closeDeleteModalMock);
+ expect(window.confirmExclude).toBe(confirmExcludeMock);
+ expect(window.closeExcludeModal).toBe(closeExcludeModalMock);
+ expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
+ });
+
+ it('initializes shared page features and syncs active filters', async () => {
+ const manager = new OtherPageManager();
+
+ await manager.initialize();
+
+ expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
+ expect(initActiveFiltersSyncMock).toHaveBeenCalledWith('other');
+ });
+
+ it('boots the other models page through the initializer', async () => {
+ const manager = await initializeOtherPage();
+
+ expect(initializeAppMock).toHaveBeenCalledTimes(1);
+ expect(manager).toBeInstanceOf(OtherPageManager);
+ expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
+ });
+});
diff --git a/tests/frontend/utils/pageFixtures.js b/tests/frontend/utils/pageFixtures.js
index 465ea8b3..df8bcb24 100644
--- a/tests/frontend/utils/pageFixtures.js
+++ b/tests/frontend/utils/pageFixtures.js
@@ -36,6 +36,18 @@ export function renderEmbeddingsPage() {
});
}
+/**
+ * Renders the Other Models page template with expected dataset attributes.
+ * @returns {Element}
+ */
+export function renderOtherPage() {
+ return renderTemplate('other.html', {
+ dataset: {
+ page: 'other',
+ },
+ });
+}
+
/**
* Renders the Recipes page template with expected dataset attributes.
* @returns {Element}