Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "A simple CSV parser - BountyPay workflow demo",
"main": "src/parser.js",
"scripts": {
"test": "node test/parser.test.js"
"test": "node test/parser.test.js && node test/bounty-list.test.js"
},
"license": "MIT"
}
49 changes: 49 additions & 0 deletions src/bounty-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const DEFAULT_ERROR_MESSAGE = 'Unable to load bounties. Please try again.';

function createBountyListController({
fetchBounties,
renderItems,
renderError,
showLoading,
hideLoading,
clearError = () => {},
}) {
const required = { fetchBounties, renderItems, renderError, showLoading, hideLoading };
for (const [name, fn] of Object.entries(required)) {
if (typeof fn !== 'function') {
throw new TypeError(`${name} must be a function`);
}
}

async function loadBounties() {
showLoading();
clearError();

try {
const bounties = await fetchBounties();
renderItems(Array.isArray(bounties) ? bounties : []);
return { ok: true, bounties: Array.isArray(bounties) ? bounties : [] };
} catch (error) {
const message = error && error.message ? error.message : DEFAULT_ERROR_MESSAGE;
renderError(message);
return { ok: false, error: message };
} finally {
hideLoading();
}
}

return { loadBounties };
}

function createLoadingSpinnerMarkup(text = 'Loading bounties...') {
return `<div class="loading-spinner" role="status" aria-live="polite">
<span class="loading-spinner__icon" aria-hidden="true"></span>
<span>${text}</span>
</div>`;
}

module.exports = {
DEFAULT_ERROR_MESSAGE,
createBountyListController,
createLoadingSpinnerMarkup,
};
88 changes: 88 additions & 0 deletions test/bounty-list.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const {
createBountyListController,
createLoadingSpinnerMarkup,
} = require('../src/bounty-list');

let passed = 0;
let failed = 0;

function assert(name, condition) {
if (condition) {
console.log(` OK ${name}`);
passed++;
} else {
console.log(` FAIL ${name}`);
failed++;
}
}

function createDeferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});

return { promise, resolve, reject };
}

async function testShowsSpinnerDuringLoad() {
const calls = [];
const deferred = createDeferred();
const controller = createBountyListController({
fetchBounties: () => deferred.promise,
renderItems: (items) => calls.push(['renderItems', items]),
renderError: (message) => calls.push(['renderError', message]),
showLoading: () => calls.push(['showLoading']),
hideLoading: () => calls.push(['hideLoading']),
});

const loading = controller.loadBounties();
assert('shows spinner before data resolves', calls[0][0] === 'showLoading');
assert('spinner remains visible while request is pending', calls.length === 1);

deferred.resolve([{ id: 18, title: 'Loading spinner', reward: 50 }]);
const result = await loading;

assert('returns loaded bounties', result.ok && result.bounties.length === 1);
assert('renders items after successful fetch', calls[1][0] === 'renderItems');
assert('hides spinner after data loads', calls[calls.length - 1][0] === 'hideLoading');
}

async function testHidesSpinnerAndRendersError() {
const calls = [];
const controller = createBountyListController({
fetchBounties: async () => {
throw new Error('Network unavailable');
},
renderItems: (items) => calls.push(['renderItems', items]),
renderError: (message) => calls.push(['renderError', message]),
showLoading: () => calls.push(['showLoading']),
hideLoading: () => calls.push(['hideLoading']),
});

const result = await controller.loadBounties();

assert('returns failed result for fetch errors', result.ok === false);
assert('renders friendly error state', calls.some(([name]) => name === 'renderError'));
assert('hides spinner after failed fetch', calls[calls.length - 1][0] === 'hideLoading');
}

function testSpinnerMarkupIsAccessible() {
const markup = createLoadingSpinnerMarkup();

assert('spinner exposes status role', markup.includes('role="status"'));
assert('spinner announces loading text politely', markup.includes('aria-live="polite"'));
}

(async () => {
console.log('\nBounty list loading tests\n');

await testShowsSpinnerDuringLoad();
await testHidesSpinnerAndRendersError();
testSpinnerMarkupIsAccessible();

console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
})();