mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-06 01:45:31 -03:00
refactor(update)!: make obtainability a property of updates, not a surface
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the "Price alert threshold" number meant, or what "Price Alerts" was alerting about. That was not a copy problem: the implementation exposed our mechanism (a page scrape) and our SQL predicates as the user's concepts. Two concrete defects came from the same root: - the alert population included versions the user already owns (neither the event generator nor the query filtered on is_in_library; in the owner's library 28 of 52 gated versions were already downloaded, so most "alerts" were about files already on disk, which cannot become cheaper *for them*); - a threshold-filtered state list lived in a notification surface, so an empty panel had three indistinguishable causes and read as a broken feature. The information model is now the version plus ownership: cost is shown only where a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and gated -> the price when it is known, `Paid` without a number when it is not, and early access keeps its countdown because "free on <date>" decides between waiting and paying. The numeric threshold has no place in that model: every decision is categorical (wait / pay / skip), so the setting, the comparison and the whole alert-state machine are gone. - both alert-state columns are removed from the schema rather than left dead; a database created by an unreleased build has them dropped on open (native ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else - gate events are emitted only for versions the user does not have, and the price-drop event goes with the threshold it belonged to - both alert endpoints, PriceAlertsHandler and the service-registry adapter field it needed are removed: events already reach the UI through the refresh response - the bell tab, panel, CSS, both entry points, the unread watermark and their locales are removed; the setting keeps only the enable flag and the refresh interval and is framed as plumbing - "Price unavailable" is replaced by `Paid`: the gate is certain from the public API, only the number is best-effort, and that is our plumbing, not the user's problem Verified against a copy of the owner's real database: 52 gated versions -> 28 owned (now silent) + 24 the feature is actually about; the drop migration ran and both removed endpoints 404.
This commit is contained in:
@@ -1,300 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user