Files
Will Miao e21be66012 fix(modal): keep cached metadata when the model drive is offline
A model on a switched-off drive lost its creator and license icons in the
modal even though "search by creator" found it: the list payload is
trimmed to id/modelId/name/trainedWords, and the only full-payload source
was the .metadata.json next to the model file.

- carry `creator` in the lightweight list payload so the card/modal
  fallback keeps who published the model;
- add a cache fallback to GET /api/lm/{type}/metadata: when the sidecar
  cannot be read (offline drive, unmounted share, missing/corrupt file),
  rebuild the CivitAI payload from the cache entry and decode
  `license_flags` back into the permission fields the icons render from.
  The response reports `partial` and `offline` so callers can explain the
  degradation instead of silently dropping fields;
- skip the stat() for paths outside every live root in alongside storage
  so a dead share cannot stall the modal; centralized storage still reads
  the real sidecar and serves the full payload while the drive is off;
- merge the fetched payload into the list data instead of replacing it
  (a partial response must never drop cached fields), and show an
  offline/partial notice plus a description-tab explanation.
2026-11-14 03:16:27 +08:00

145 lines
4.5 KiB
JavaScript

import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
MODAL_MODULE,
API_FACTORY,
UI_HELPERS_MODULE,
MODAL_MANAGER_MODULE,
SHOWCASE_MODULE,
MODEL_TAGS_MODULE,
UTILS_MODULE,
TRIGGER_WORDS_MODULE,
PRESET_TAGS_MODULE,
MODEL_VERSIONS_MODULE,
RECIPE_TAB_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitai: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn((id, html) => {
document.body.innerHTML = `<div id="${id}">${html}</div>`;
}),
closeModal: vi.fn(),
},
}));
vi.mock(SHOWCASE_MODULE, () => ({
scrollToTop: vi.fn(),
loadExampleImages: vi.fn(),
}));
vi.mock(MODEL_TAGS_MODULE, () => ({
setupTagEditMode: vi.fn(),
}));
vi.mock(UTILS_MODULE, async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
renderCompactTags: vi.fn(() => ''),
setupTagTooltip: vi.fn(),
formatFileSize: vi.fn(() => '1 MB'),
};
});
vi.mock(TRIGGER_WORDS_MODULE, () => ({
renderTriggerWords: vi.fn(() => ''),
setupTriggerWordsEditMode: vi.fn(),
}));
vi.mock(PRESET_TAGS_MODULE, () => ({
parsePresets: vi.fn(() => ({})),
renderPresetTags: vi.fn(() => ''),
}));
vi.mock(MODEL_VERSIONS_MODULE, () => ({
initVersionsTab: vi.fn(() => ({
load: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForModel: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(),
}));
describe('Model modal license rendering', () => {
let getModelApiClient;
let state;
beforeEach(async () => {
document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY));
getModelApiClient.mockReset();
// Import state and force classic icons for this test
const stateModule = await import('../../../static/js/state/index.js');
state = stateModule.state;
state.global.settings.use_new_license_icons = false;
});
it('handles aggregated commercial strings without extra restrictions (classic style)', async () => {
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
getModelApiClient.mockReturnValue({
fetchModelMetadataWithStatus,
saveModelMetadata: vi.fn(),
});
const { showModelModal } = await import(MODAL_MODULE);
await showModelModal(
{
model_name: 'Aggregated',
file_path: 'models/agg.safetensors',
file_name: 'agg.safetensors',
civitai: {
model: {
allowNoCredit: true,
allowCommercialUse: '{Image,RentCivit,Rent}',
allowDerivatives: true,
allowDifferentLicense: false,
},
},
},
'loras',
);
const iconTitles = Array.from(document.querySelectorAll('.license-restrictions .license-icon')).map(icon => icon.getAttribute('title'));
expect(iconTitles).toEqual(['No selling models', 'Same permissions required']);
});
});