|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import fc from 'fast-check';
|
|
|
import { JSDOM } from 'jsdom';
|
|
|
import fs from 'fs';
|
|
|
import path from 'path';
|
|
|
import { fileURLToPath } from 'url';
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
|
|
|
|
|
const uiFeedbackPath = path.join(__dirname, '..', 'static', 'js', 'ui-feedback.js');
|
|
|
const uiFeedbackCode = fs.readFileSync(uiFeedbackPath, 'utf-8');
|
|
|
|
|
|
|
|
|
async function createTestEnvironment() {
|
|
|
const html = `
|
|
|
<!DOCTYPE html>
|
|
|
<html>
|
|
|
<head></head>
|
|
|
<body>
|
|
|
<script>${uiFeedbackCode}</script>
|
|
|
</body>
|
|
|
</html>
|
|
|
`;
|
|
|
|
|
|
const dom = new JSDOM(html, {
|
|
|
url: 'http://localhost',
|
|
|
runScripts: 'dangerously',
|
|
|
resources: 'usable'
|
|
|
});
|
|
|
|
|
|
const { window } = dom;
|
|
|
const { document } = window;
|
|
|
|
|
|
|
|
|
await new Promise(resolve => {
|
|
|
if (document.readyState === 'complete') {
|
|
|
resolve();
|
|
|
} else {
|
|
|
window.addEventListener('load', resolve);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
|
|
return { window, document };
|
|
|
}
|
|
|
|
|
|
|
|
|
function createMockFetch(shouldFail, statusCode, errorMessage) {
|
|
|
return async (url, options) => {
|
|
|
if (shouldFail) {
|
|
|
if (statusCode) {
|
|
|
|
|
|
return {
|
|
|
ok: false,
|
|
|
status: statusCode,
|
|
|
statusText: errorMessage || 'Error',
|
|
|
text: async () => errorMessage || 'Request failed',
|
|
|
json: async () => { throw new Error('Invalid JSON'); }
|
|
|
};
|
|
|
} else {
|
|
|
|
|
|
throw new Error(errorMessage || 'Network error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
ok: true,
|
|
|
status: 200,
|
|
|
json: async () => ({ data: 'success' })
|
|
|
};
|
|
|
};
|
|
|
}
|
|
|
|
|
|
console.log('Running Property-Based Tests for ui-feedback.js...\n');
|
|
|
|
|
|
async function runTests() {
|
|
|
console.log('Property 4.1: fetchJSON should display error toast on HTTP errors');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.integer({ min: 400, max: 599 }),
|
|
|
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
|
|
async (statusCode, errorMessage) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
window.fetch = createMockFetch(true, statusCode, errorMessage);
|
|
|
|
|
|
|
|
|
let toastCreated = false;
|
|
|
let toastType = null;
|
|
|
let toastContent = null;
|
|
|
|
|
|
|
|
|
if (!window.UIFeedback) {
|
|
|
throw new Error('UIFeedback not defined on window');
|
|
|
}
|
|
|
|
|
|
|
|
|
const originalToast = window.UIFeedback.toast;
|
|
|
window.UIFeedback.toast = (type, title, message) => {
|
|
|
toastCreated = true;
|
|
|
toastType = type;
|
|
|
toastContent = { title, message };
|
|
|
|
|
|
originalToast(type, title, message);
|
|
|
};
|
|
|
|
|
|
|
|
|
let errorThrown = false;
|
|
|
try {
|
|
|
await window.UIFeedback.fetchJSON('/api/test', {}, 'Test Context');
|
|
|
} catch (err) {
|
|
|
errorThrown = true;
|
|
|
}
|
|
|
|
|
|
|
|
|
if (!toastCreated) {
|
|
|
throw new Error(`No toast created for HTTP ${statusCode} error`);
|
|
|
}
|
|
|
|
|
|
if (toastType !== 'error') {
|
|
|
throw new Error(`Expected error toast, got ${toastType}`);
|
|
|
}
|
|
|
|
|
|
if (!errorThrown) {
|
|
|
throw new Error('fetchJSON should throw error on HTTP failure');
|
|
|
}
|
|
|
|
|
|
|
|
|
const toastStack = document.querySelector('.toast-stack');
|
|
|
if (!toastStack) {
|
|
|
throw new Error('Toast stack not found in DOM');
|
|
|
}
|
|
|
|
|
|
const errorToasts = toastStack.querySelectorAll('.toast.error');
|
|
|
if (errorToasts.length === 0) {
|
|
|
throw new Error('No error toast found in toast stack');
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.1 passed: HTTP errors trigger error toasts\n');
|
|
|
|
|
|
console.log('Property 4.2: fetchJSON should display error toast on network errors');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
|
|
|
async (errorMessage) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
window.fetch = createMockFetch(true, null, errorMessage);
|
|
|
|
|
|
|
|
|
let toastCreated = false;
|
|
|
let toastType = null;
|
|
|
|
|
|
|
|
|
const originalToast = window.UIFeedback.toast;
|
|
|
window.UIFeedback.toast = (type, title, message) => {
|
|
|
toastCreated = true;
|
|
|
toastType = type;
|
|
|
originalToast(type, title, message);
|
|
|
};
|
|
|
|
|
|
|
|
|
let errorThrown = false;
|
|
|
try {
|
|
|
await window.UIFeedback.fetchJSON('/api/test', {}, 'Test Context');
|
|
|
} catch (err) {
|
|
|
errorThrown = true;
|
|
|
}
|
|
|
|
|
|
|
|
|
if (!toastCreated) {
|
|
|
throw new Error('No toast created for network error');
|
|
|
}
|
|
|
|
|
|
if (toastType !== 'error') {
|
|
|
throw new Error(`Expected error toast, got ${toastType}`);
|
|
|
}
|
|
|
|
|
|
if (!errorThrown) {
|
|
|
throw new Error('fetchJSON should throw error on network failure');
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.2 passed: Network errors trigger error toasts\n');
|
|
|
|
|
|
console.log('Property 4.3: fetchJSON should return data on success');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
|
async (urlPath) => {
|
|
|
const { window } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
const mockData = { result: 'success', path: urlPath };
|
|
|
window.fetch = async () => ({
|
|
|
ok: true,
|
|
|
status: 200,
|
|
|
json: async () => mockData
|
|
|
});
|
|
|
|
|
|
|
|
|
let errorToastCreated = false;
|
|
|
|
|
|
|
|
|
const originalToast = window.UIFeedback.toast;
|
|
|
window.UIFeedback.toast = (type, title, message) => {
|
|
|
if (type === 'error') {
|
|
|
errorToastCreated = true;
|
|
|
}
|
|
|
originalToast(type, title, message);
|
|
|
};
|
|
|
|
|
|
|
|
|
const result = await window.UIFeedback.fetchJSON(`/api/${urlPath}`, {}, 'Test');
|
|
|
|
|
|
|
|
|
if (errorToastCreated) {
|
|
|
throw new Error('Error toast created for successful request');
|
|
|
}
|
|
|
|
|
|
|
|
|
if (JSON.stringify(result) !== JSON.stringify(mockData)) {
|
|
|
throw new Error('fetchJSON did not return correct data');
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.3 passed: Successful requests return data without error toasts\n');
|
|
|
|
|
|
console.log('Property 4.4: toast function should create visible toast elements');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.constantFrom('success', 'error', 'warning', 'info'),
|
|
|
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
|
|
|
fc.option(fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), { nil: null }),
|
|
|
async (type, title, message) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
window.UIFeedback.toast(type, title, message);
|
|
|
|
|
|
|
|
|
const toastStack = document.querySelector('.toast-stack');
|
|
|
if (!toastStack) {
|
|
|
throw new Error('Toast stack not found');
|
|
|
}
|
|
|
|
|
|
const toasts = toastStack.querySelectorAll(`.toast.${type}`);
|
|
|
if (toasts.length === 0) {
|
|
|
throw new Error(`No ${type} toast found in stack`);
|
|
|
}
|
|
|
|
|
|
const lastToast = toasts[toasts.length - 1];
|
|
|
const toastHTML = lastToast.innerHTML;
|
|
|
|
|
|
|
|
|
if (!toastHTML.includes(title)) {
|
|
|
throw new Error(`Toast does not contain title: ${title}`);
|
|
|
}
|
|
|
|
|
|
|
|
|
if (message && !toastHTML.includes(message)) {
|
|
|
throw new Error(`Toast does not contain message: ${message}`);
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.4 passed: Toast function creates visible elements\n');
|
|
|
|
|
|
console.log('Property 4.5: setBadge should update element class and text');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
|
|
|
fc.constantFrom('info', 'success', 'warning', 'danger'),
|
|
|
async (text, tone) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
const badge = document.createElement('span');
|
|
|
badge.className = 'badge';
|
|
|
document.body.appendChild(badge);
|
|
|
|
|
|
|
|
|
window.UIFeedback.setBadge(badge, text, tone);
|
|
|
|
|
|
|
|
|
if (badge.textContent !== text) {
|
|
|
throw new Error(`Badge text not set correctly. Expected: ${text}, Got: ${badge.textContent}`);
|
|
|
}
|
|
|
|
|
|
|
|
|
if (!badge.classList.contains('badge')) {
|
|
|
throw new Error('Badge should have "badge" class');
|
|
|
}
|
|
|
|
|
|
if (!badge.classList.contains(tone)) {
|
|
|
throw new Error(`Badge should have "${tone}" class`);
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.5 passed: setBadge updates element correctly\n');
|
|
|
|
|
|
console.log('Property 4.6: showLoading should display loading indicator');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.option(fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), { nil: undefined }),
|
|
|
async (message) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
const container = document.createElement('div');
|
|
|
container.id = 'test-container';
|
|
|
document.body.appendChild(container);
|
|
|
|
|
|
|
|
|
window.UIFeedback.showLoading(container, message);
|
|
|
|
|
|
|
|
|
const loadingIndicator = container.querySelector('.loading-indicator');
|
|
|
if (!loadingIndicator) {
|
|
|
throw new Error('Loading indicator not found');
|
|
|
}
|
|
|
|
|
|
|
|
|
const expectedMessage = message || 'Loading data...';
|
|
|
if (!loadingIndicator.textContent.includes(expectedMessage)) {
|
|
|
throw new Error(`Loading indicator does not contain expected message: ${expectedMessage}`);
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.6 passed: showLoading displays loading indicator\n');
|
|
|
|
|
|
console.log('Property 4.7: fadeReplace should update container content');
|
|
|
|
|
|
|
|
|
await fc.assert(
|
|
|
fc.asyncProperty(
|
|
|
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
|
|
|
async (html) => {
|
|
|
const { window, document } = await createTestEnvironment();
|
|
|
|
|
|
|
|
|
const container = document.createElement('div');
|
|
|
container.id = 'test-container';
|
|
|
container.innerHTML = '<p>Old content</p>';
|
|
|
document.body.appendChild(container);
|
|
|
|
|
|
|
|
|
window.UIFeedback.fadeReplace(container, html);
|
|
|
|
|
|
|
|
|
if (container.innerHTML !== html) {
|
|
|
throw new Error('Container content not replaced');
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
),
|
|
|
{ numRuns: 50, verbose: true }
|
|
|
);
|
|
|
|
|
|
console.log('β Property 4.7 passed: fadeReplace updates container content\n');
|
|
|
|
|
|
console.log('\nβ All property-based tests for ui-feedback.js passed!');
|
|
|
console.log('β Property 4: Error toast display validated successfully');
|
|
|
}
|
|
|
|
|
|
runTests().catch(err => {
|
|
|
console.error('Test failed:', err);
|
|
|
process.exit(1);
|
|
|
});
|
|
|
|