mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
The default threshold is 0 ("only tell me when a version becomes free") and the
settings copy says so, but the panel did not: a real instance with 52 priced paid
versions and an untouched threshold showed "Nothing is under your price threshold
right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads
as a broken feature.
- the payload now carries pricedCount, so the empty state can say how many paid
versions already have a known price
- when the threshold is 0 and prices are known, the empty state says so and
points at Settings - Library instead of implying there is nothing to show
- the read-time threshold comparison means setting one takes effect immediately;
measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44,
500 -> 48, 5000 -> 51
301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
|
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
|
import { state } from '../../../static/js/state/index.js';
|
|
import { modalManager } from '../../../static/js/managers/ModalManager.js';
|
|
|
|
/**
|
|
* The price alerts panel is rendered into the notification bell modal, so the
|
|
* tests build the same element ids the template provides and drive the service
|
|
* directly.
|
|
*/
|
|
function buildPanelDom() {
|
|
document.body.innerHTML = `
|
|
<div id="updateModal">
|
|
<button data-notification-tab="updates"></button>
|
|
<button data-notification-tab="priceAlerts"></button>
|
|
<div data-notification-panel="updates"></div>
|
|
<div data-notification-panel="priceAlerts">
|
|
<div id="priceAlertsDisabled" class="hidden"></div>
|
|
<button id="priceAlertsSettingsBtn"></button>
|
|
<button data-price-alert-segment="below_threshold"></button>
|
|
<button data-price-alert-segment="became_free"></button>
|
|
<span id="priceAlertsThreshold"></span>
|
|
<p id="priceAlertsStale" class="hidden"></p>
|
|
<p id="priceAlertsEmpty"></p>
|
|
<ul id="priceAlertsList"></ul>
|
|
</div>
|
|
<span id="updatesTabBadge"></span>
|
|
<span id="bannerTabBadge"></span>
|
|
<span id="priceAlertsTabBadge"></span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function alertPayload(alerts, overrides = {}) {
|
|
return {
|
|
success: true,
|
|
enabled: true,
|
|
thresholdBuzz: 500,
|
|
newestCheckedAt: 1791039694.5,
|
|
alerts,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const BELOW_THRESHOLD_ALERT = {
|
|
modelId: 2981320,
|
|
modelType: 'checkpoint',
|
|
modelName: 'Glorious Art',
|
|
versionId: 3379626,
|
|
versionName: 'Alpha',
|
|
kind: 'below_threshold',
|
|
priceBuzz: 250,
|
|
listPriceBuzz: 500,
|
|
acceptsBlueBuzz: true,
|
|
priceAlertSince: 1791000000,
|
|
isInLibrary: false,
|
|
filePath: null,
|
|
civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626',
|
|
};
|
|
|
|
const BECAME_FREE_ALERT = {
|
|
modelId: 1802980,
|
|
modelType: 'lora',
|
|
modelName: 'Eira Kishida',
|
|
versionId: 3262917,
|
|
versionName: 'v3',
|
|
kind: 'became_free',
|
|
priceBuzz: null,
|
|
priceAlertSince: null,
|
|
gateLapsedAt: '2026-09-28T00:00:00.000Z',
|
|
isInLibrary: true,
|
|
filePath: '/models/loras/eira.safetensors',
|
|
civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917',
|
|
};
|
|
|
|
function createFetchResponse(payload, ok = true) {
|
|
return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' };
|
|
}
|
|
|
|
describe('UpdateService price alerts panel', () => {
|
|
let service;
|
|
|
|
beforeEach(() => {
|
|
buildPanelDom();
|
|
localStorage.clear();
|
|
state.global = state.global || {};
|
|
state.global.settings = state.global.settings || {};
|
|
service = new UpdateService();
|
|
service.setupNotificationCenter();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.fetch;
|
|
document.body.innerHTML = '';
|
|
localStorage.clear();
|
|
});
|
|
|
|
it('loads the global endpoint and renders a below-threshold row', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts');
|
|
|
|
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].textContent).toContain('Glorious Art');
|
|
expect(items[0].textContent).toContain('250 Buzz');
|
|
// The stored list price is shown struck through.
|
|
expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz');
|
|
expect(items[0].textContent).toContain('Blue Buzz OK');
|
|
expect(items[0].textContent).toContain('Not in library');
|
|
// No local file -> no "Open" action, only CivitAI.
|
|
expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull();
|
|
expect(
|
|
items[0].querySelector('[data-price-alert-action="civitai"]')
|
|
).not.toBeNull();
|
|
expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true);
|
|
expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true);
|
|
});
|
|
|
|
it('separates became-free alerts into their own segment', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
// Below-threshold is the default segment.
|
|
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
|
|
|
|
service.priceAlertSegment = 'became_free';
|
|
service.renderPriceAlerts();
|
|
|
|
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].textContent).toContain('Free now');
|
|
expect(items[0].textContent).toContain('In library');
|
|
// In-library row offers both actions.
|
|
expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull();
|
|
expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull();
|
|
});
|
|
|
|
it('counts unread alerts and clears the count once viewed', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
expect(service.getUnreadPriceAlertCount()).toBe(2);
|
|
const badge = document.getElementById('priceAlertsTabBadge');
|
|
expect(badge.classList.contains('visible')).toBe(true);
|
|
expect(badge.textContent).toBe('2');
|
|
|
|
service.markPriceAlertsViewed();
|
|
|
|
expect(service.getUnreadPriceAlertCount()).toBe(0);
|
|
expect(badge.classList.contains('visible')).toBe(false);
|
|
|
|
// A later alert is unread again.
|
|
service.priceAlerts = [
|
|
{ ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 },
|
|
];
|
|
expect(service.refreshUnreadPriceAlertCount()).toBe(1);
|
|
});
|
|
|
|
it('explains itself while price tracking is off', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
createFetchResponse(
|
|
alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 })
|
|
)
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
expect(
|
|
document.getElementById('priceAlertsDisabled').classList.contains('hidden')
|
|
).toBe(false);
|
|
expect(document.getElementById('priceAlertsThreshold').textContent).toBe('');
|
|
});
|
|
|
|
it('shows the empty state when the segment has no matches', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
service.priceAlertSegment = 'became_free';
|
|
service.renderPriceAlerts();
|
|
|
|
const empty = document.getElementById('priceAlertsEmpty');
|
|
expect(empty.classList.contains('hidden')).toBe(false);
|
|
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0);
|
|
});
|
|
|
|
it('reports how many gated versions have no readable price', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
createFetchResponse(
|
|
alertPayload([BELOW_THRESHOLD_ALERT], { unavailableCount: 3 })
|
|
)
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
expect(service.priceAlertsUnavailableCount).toBe(3);
|
|
const note = document.getElementById('priceAlertsStale');
|
|
expect(note.classList.contains('hidden')).toBe(false);
|
|
expect(note.textContent).toContain('3 paid version(s) have no readable price');
|
|
});
|
|
|
|
it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => {
|
|
// The exact confusion a real user hit: 52 priced versions, threshold 0,
|
|
// and an empty panel saying "nothing is under your threshold".
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
createFetchResponse(
|
|
alertPayload([], { thresholdBuzz: 0, pricedCount: 52 })
|
|
)
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
const empty = document.getElementById('priceAlertsEmpty');
|
|
expect(empty.classList.contains('hidden')).toBe(false);
|
|
expect(empty.textContent).toContain('52 paid version(s) have a known price');
|
|
expect(empty.textContent).toContain('threshold is 0 Buzz');
|
|
});
|
|
|
|
it('keeps the plain empty message when a real threshold is set', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
createFetchResponse(
|
|
alertPayload([], { thresholdBuzz: 500, pricedCount: 52 })
|
|
)
|
|
);
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
const empty = document.getElementById('priceAlertsEmpty');
|
|
expect(empty.textContent).toBe('Nothing is under your price threshold right now.');
|
|
});
|
|
|
|
it('keeps the last known list when the request fails', async () => {
|
|
global.fetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])))
|
|
.mockResolvedValueOnce(createFetchResponse({ success: false, error: 'boom' }, false));
|
|
|
|
await service.loadPriceAlerts({ force: true });
|
|
await service.loadPriceAlerts({ force: true });
|
|
|
|
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
|
|
const stale = document.getElementById('priceAlertsStale');
|
|
expect(stale.classList.contains('hidden')).toBe(false);
|
|
});
|
|
|
|
it('switches to the tab without closing an already-open bell', () => {
|
|
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
|
|
const toggleSpy = vi.spyOn(service, 'toggleUpdateModal');
|
|
const getModalSpy = vi
|
|
.spyOn(modalManager, 'getModal')
|
|
.mockReturnValue({ isOpen: true });
|
|
|
|
service.openPriceAlertsTab();
|
|
|
|
expect(getModalSpy).toHaveBeenCalledWith('updateModal');
|
|
// toggleUpdateModal() would have *closed* the open bell.
|
|
expect(toggleSpy).not.toHaveBeenCalled();
|
|
expect(service.activeNotificationTab).toBe('priceAlerts');
|
|
|
|
getModalSpy.mockRestore();
|
|
toggleSpy.mockRestore();
|
|
});
|
|
|
|
it('opens the bell when it is closed', () => {
|
|
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
|
|
const toggleSpy = vi
|
|
.spyOn(service, 'toggleUpdateModal')
|
|
.mockImplementation(() => {});
|
|
const getModalSpy = vi
|
|
.spyOn(modalManager, 'getModal')
|
|
.mockReturnValue({ isOpen: false });
|
|
|
|
service.openPriceAlertsTab();
|
|
|
|
expect(toggleSpy).toHaveBeenCalled();
|
|
expect(service.activeNotificationTab).toBe('priceAlerts');
|
|
|
|
getModalSpy.mockRestore();
|
|
toggleSpy.mockRestore();
|
|
});
|
|
});
|