| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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);
|
| });
|
|
|