import { beforeEach, describe, expect, it, vi } from "vitest"; const { TRIGGER_WORDS_MODULE, I18N_HELPERS_MODULE, UI_HELPERS_MODULE, MODEL_API_MODULE, saveModelMetadataMock, } = vi.hoisted(() => ({ TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname, I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, saveModelMetadataMock: vi.fn(), })); vi.mock(I18N_HELPERS_MODULE, () => ({ translate: vi.fn((key, params, fallback) => fallback || key), })); vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), copyToClipboard: vi.fn(), })); vi.mock(MODEL_API_MODULE, () => ({ getModelApiClient: vi.fn(() => ({ saveModelMetadata: saveModelMetadataMock, })), })); describe("TriggerWords reordering", () => { let renderTriggerWords; let setupTriggerWordsEditMode; beforeEach(async () => { document.body.innerHTML = ''; vi.clearAllMocks(); saveModelMetadataMock.mockResolvedValue({}); global.fetch = vi.fn(async () => ({ json: async () => ({ success: true, trained_words: [], class_tokens: null, }), })); const module = await import(TRIGGER_WORDS_MODULE); renderTriggerWords = module.renderTriggerWords; setupTriggerWordsEditMode = module.setupTriggerWordsEditMode; }); function section() { return document.querySelector('.trigger-words'); } function order() { return Array.from(document.querySelectorAll('.trigger-word-tag')) .map((tag) => tag.dataset.word); } function handles() { return Array.from(document.querySelectorAll('.reorder-handle')); } function firePointer(type, target, init = {}) { target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, ...init, })); } function dragToEnd(handle) { firePointer('pointerdown', handle); firePointer('pointermove', handle, { clientY: 999 }); firePointer('pointerup', handle, { clientY: 999 }); } function pressKey(handle, key, init = {}) { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init, }); handle.dispatchEvent(event); return event; } async function enterEditMode(words = ["alpha", "beta", "gamma"]) { document.body.innerHTML = renderTriggerWords(words, "test.safetensors"); setupTriggerWordsEditMode(); document.querySelector('.edit-trigger-words-btn') .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy(); }); } it("renders a handle per word but only offers reordering for 2+ words", async () => { await enterEditMode(["alpha", "beta"]); expect(handles()).toHaveLength(2); expect(section().classList.contains('has-sortable-words')).toBe(true); }); it("does not offer reordering when there is a single word", async () => { await enterEditMode(["alpha"]); expect(handles()).toHaveLength(1); expect(section().classList.contains('has-sortable-words')).toBe(false); }); it("reorders a word by dragging its handle and swallows the follow-up click", async () => { await enterEditMode(); dragToEnd(handles()[0]); expect(order()).toEqual(["beta", "gamma", "alpha"]); // The click generated by the drag must not open the inline editor const movedTag = document.querySelector('.trigger-word-tag[data-word="alpha"]'); const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); movedTag.dispatchEvent(clickEvent); expect(clickEvent.defaultPrevented).toBe(true); expect(movedTag.querySelector('.trigger-word-edit-input')).toBeNull(); }); it("does not start a drag from the tag body", async () => { await enterEditMode(); const content = document.querySelector('.trigger-word-content'); firePointer('pointerdown', content); expect(document.body.classList.contains('reorder-drag-active')).toBe(false); firePointer('pointermove', content, { clientY: 999 }); firePointer('pointerup', content, { clientY: 999 }); expect(order()).toEqual(["alpha", "beta", "gamma"]); expect(document.querySelector('.reorder-dragging')).toBeNull(); }); it("reorders with the keyboard and saves the new order", async () => { await enterEditMode(); pressKey(handles()[0], 'ArrowRight', { altKey: true }); expect(order()).toEqual(["beta", "alpha", "gamma"]); pressKey(handles()[2], 'ArrowUp', { altKey: true }); expect(order()).toEqual(["beta", "gamma", "alpha"]); document.querySelector('.metadata-save-btn') .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(saveModelMetadataMock).toHaveBeenCalled(); }); expect(saveModelMetadataMock).toHaveBeenCalledWith("test.safetensors", { civitai: { trainedWords: ["beta", "gamma", "alpha"] }, }); }); it("swallows the reorder shortcut at the ends of the list", async () => { await enterEditMode(); const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true }); expect(event.defaultPrevented).toBe(true); expect(order()).toEqual(["alpha", "beta", "gamma"]); }); it("ignores the reorder shortcut without the Alt modifier", async () => { await enterEditMode(); pressKey(handles()[0], 'ArrowRight'); expect(order()).toEqual(["alpha", "beta", "gamma"]); }); it("announces moved words for screen readers", async () => { await enterEditMode(); pressKey(handles()[0], 'ArrowRight', { altKey: true }); const liveRegion = section().querySelector('.reorder-live-region'); expect(liveRegion.getAttribute('aria-live')).toBe('polite'); expect(liveRegion.textContent).toBe( 'Moved to position 2 of 3', ); }); it("updates handle labels with the current position", async () => { await enterEditMode(); expect(handles()[0].getAttribute('aria-label')) .toBe('Reorder alpha, position 1 of 3'); pressKey(handles()[0], 'ArrowRight', { altKey: true }); expect(handles()[0].getAttribute('aria-label')) .toBe('Reorder beta, position 1 of 3'); expect(handles()[1].getAttribute('aria-label')) .toBe('Reorder alpha, position 2 of 3'); }); it("restores the original order when edit mode is canceled", async () => { await enterEditMode(); dragToEnd(handles()[0]); expect(order()).toEqual(["beta", "gamma", "alpha"]); document.querySelector('.edit-trigger-words-btn') .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(order()).toEqual(["alpha", "beta", "gamma"]); }); it("wires reordering for words added while editing", async () => { await enterEditMode(["alpha", "beta"]); const input = document.querySelector('.metadata-input'); input.value = 'gamma'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, })); expect(order()).toEqual(["alpha", "beta", "gamma"]); expect(handles()).toHaveLength(3); expect(section().classList.contains('has-sortable-words')).toBe(true); // The freshly added word is draggable too const newHandle = document.querySelector( '.trigger-word-tag[data-word="gamma"] .reorder-handle', ); firePointer('pointerdown', newHandle); firePointer('pointermove', newHandle, { clientY: -999 }); firePointer('pointerup', newHandle, { clientY: -999 }); expect(order()).toEqual(["gamma", "alpha", "beta"]); }); it("stops handling drags once edit mode is left", async () => { await enterEditMode(); document.querySelector('.edit-trigger-words-btn') .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); dragToEnd(handles()[0]); expect(order()).toEqual(["alpha", "beta", "gamma"]); expect(document.body.classList.contains('reorder-drag-active')).toBe(false); // The grip must not be offered anymore outside edit mode expect(section().classList.contains('has-sortable-words')).toBe(false); expect( document.querySelector('.trigger-words-tags').classList.contains('pointer-sort-enabled'), ).toBe(false); }); });