189 lines
11 KiB
JavaScript
189 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import vm from 'node:vm';
|
|
|
|
const source = readFileSync(new URL('../egbim/app.js', import.meta.url), 'utf8');
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
const post = (id, title, extra = {}) => ({ id, title, content: '', company: '회사', department: '', author: '', ...extra });
|
|
const record = (id, title, extra = {}) => ({ id, title, contents: '문의 내용', Category: 'GENERAL_QNA', requester_id: 'owner', ...extra });
|
|
const ids = (posts) => Array.from(posts, (item) => item.id);
|
|
|
|
function element() {
|
|
return { value: '', textContent: '', innerHTML: '', hidden: false, checked: false, style: {}, listeners: {},
|
|
addEventListener(name, callback) { this.listeners[name] = callback; } };
|
|
}
|
|
|
|
function pageContext(fetchPage = async () => ({ items: [] })) {
|
|
const elements = new Map(['#qaRows', '#searchForm', '#query', '#onlyMine', '#tableEmpty', '#pagination', '#searchStatus', '#resultCount'].map((selector) => [selector, element()]));
|
|
const category = element();
|
|
category.value = '전체';
|
|
let buttons = [];
|
|
const calls = [];
|
|
const warnings = [];
|
|
const context = vm.createContext({
|
|
window: { QA_CONFIG: { pageSize: 10 }, location: { origin: 'https://qa.example.test' }, scrollTo() {} },
|
|
document: {
|
|
cookie: '', addEventListener() {},
|
|
querySelector: (selector) => selector === 'input[name=category]:checked' ? category : elements.get(selector) || null,
|
|
querySelectorAll(selector) {
|
|
if (selector === 'input[name=category], #onlyMine') return [category, elements.get('#onlyMine')];
|
|
if (selector === '#pagination button') {
|
|
buttons = Array.from(elements.get('#pagination').innerHTML.matchAll(/<button([^>]*)>/g), ([, attributes]) => {
|
|
const button = element();
|
|
button.dataset = { page: /data-page="(\d+)"/.exec(attributes)[1] };
|
|
button.disabled = attributes.includes(' disabled');
|
|
return button;
|
|
});
|
|
return buttons;
|
|
}
|
|
return [];
|
|
}
|
|
},
|
|
localStorage: { getItem: () => null, setItem() {} }, sessionStorage: { getItem: () => null },
|
|
URL, URLSearchParams,
|
|
console: { warn: (...args) => warnings.push(args) },
|
|
async fetch(path, options) {
|
|
assert.equal(options.credentials, 'include');
|
|
const page = Number(new URL(path, 'https://qa.example.test').searchParams.get('page'));
|
|
calls.push(page);
|
|
return Response.json(await fetchPage(page));
|
|
}
|
|
});
|
|
vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.pageSearch = { searchPosts, loadRemotePosts, initList, setUser(user) { resolvedAuthUser = user; } }; })();'), context);
|
|
return {
|
|
search: context.pageSearch.searchPosts, load: context.pageSearch.loadRemotePosts, elements, calls, warnings, category,
|
|
html: () => elements.get('#qaRows').innerHTML,
|
|
async init() { context.pageSearch.setUser({ requesterId: 'owner', ssoSubject: 'owner' }); context.pageSearch.initList(); await flush(); },
|
|
submit(query) { elements.get('#query').value = query; elements.get('#searchForm').listeners.submit({ preventDefault() {} }); },
|
|
clickPage(page) { const button = buttons.find((item) => item.dataset.page === String(page) && !item.disabled); assert.ok(button); button.listeners.click(); },
|
|
buttonCount: () => buttons.length
|
|
};
|
|
}
|
|
|
|
test('search ranks exact title matches ahead of content matches and typos, preserving ties and input order', () => {
|
|
const page = pageContext();
|
|
const posts = [post('typo', '로그임 오류'), post('body', '사용 문의', { content: '로그인 문제' }), post('title', '로그인 오류'), post('title2', '로그인 방법')];
|
|
assert.deepEqual(ids(page.search(posts, '로그인')), ['title', 'title2', 'body', 'typo']);
|
|
assert.deepEqual(ids(page.search(posts, '')), ids(posts));
|
|
assert.deepEqual(ids(posts), ['typo', 'body', 'title', 'title2']);
|
|
});
|
|
|
|
test('search tolerates Korean and English typos, missing or extra letters and adjacent transposition', () => {
|
|
const page = pageContext();
|
|
const posts = [post('korean', '로그인 오류'), post('english', 'Login error'), post('attachment', '파일 첨부 기능'), post('unrelated', '결제 승인 요청')];
|
|
for (const [query, expected] of [['로그임', 'korean'], ['logni', 'english'], ['loggin', 'english'], ['파일첨뷰', 'attachment']]) {
|
|
assert.ok(ids(page.search(posts, query)).includes(expected), query);
|
|
}
|
|
assert.deepEqual(ids(page.search(posts, 'qzxv')), []);
|
|
});
|
|
|
|
test('search supports spacing, case, Unicode normalization and Korean initials', () => {
|
|
const page = pageContext();
|
|
const posts = [post('login', '로그인 오류'), post('bim', 'EG-BIM 파일 첨부')];
|
|
for (const query of ['로그인오류', 'ㄹㄱㅇ', '로그인']) assert.equal(page.search(posts, query)[0].id, 'login', query);
|
|
for (const query of ['파일첨부', 'eg bim', 'EG-BIM']) assert.equal(page.search(posts, query)[0].id, 'bim', query);
|
|
});
|
|
|
|
test('all query words are required and may match title, body, company, department or author', () => {
|
|
const page = pageContext();
|
|
const posts = [post('match', '로그인 오류', { company: '바론 컨설턴트', department: '설계부', author: '김작성', content: '첨부 문의' }), post('other', '로그인 오류', { department: '관리부' })];
|
|
for (const query of ['로그임 설계부', '바론컨설턴트 김작성', '첨부 ㅅㄱㅂ']) assert.deepEqual(ids(page.search(posts, query)), ['match'], query);
|
|
assert.deepEqual(ids(page.search(posts, '로그인 없는검색어')), []);
|
|
});
|
|
|
|
test('one and two character queries require a literal match to limit unrelated results', () => {
|
|
const page = pageContext();
|
|
const posts = [post('dept', '문의', { department: 'IS3' })];
|
|
assert.deepEqual(ids(page.search(posts, 'is')), ['dept']);
|
|
assert.deepEqual(ids(page.search(posts, 'it')), []);
|
|
assert.deepEqual(ids(page.search(posts, '오류')), []);
|
|
});
|
|
|
|
test('all remote pages are loaded and duplicate IDs do not duplicate search results', async () => {
|
|
const first = Array.from({ length: 100 }, (_, index) => record(String(index + 1), '일반 문의 ' + index));
|
|
const page = pageContext(async (number) => ({ items: number === 1 ? first : [first[99], record('older', '로그인 오류')], meta: { total: 101 } }));
|
|
const updates = [];
|
|
const result = await page.load((update) => updates.push(update));
|
|
assert.equal(result.posts.length, 101);
|
|
assert.deepEqual(page.calls, [1, 2]);
|
|
assert.equal(updates[0].complete, false);
|
|
assert.equal(updates[1].complete, true);
|
|
assert.deepEqual(ids(page.search(result.posts, '로그임')), ['older']);
|
|
});
|
|
|
|
test('page metadata and full pages without metadata both terminate correctly', async () => {
|
|
const withPages = pageContext(async (number) => ({ items: [record('page-' + number, '문의')], meta: { totalPages: 2 } }));
|
|
assert.equal((await withPages.load()).posts.length, 2);
|
|
assert.deepEqual(withPages.calls, [1, 2]);
|
|
const withoutMeta = pageContext(async (number) => ({ items: number === 1 ? Array.from({ length: 100 }, (_, index) => record(String(index), '문의')) : [] }));
|
|
assert.equal((await withoutMeta.load()).posts.length, 100);
|
|
assert.deepEqual(withoutMeta.calls, [1, 2]);
|
|
const empty = pageContext(async () => ({ items: [], meta: { total: 0, totalPages: 1 } }));
|
|
assert.equal((await empty.load()).posts.length, 0);
|
|
assert.deepEqual(empty.calls, [1]);
|
|
});
|
|
|
|
test('repeated or missing remote pages fail instead of looping or claiming all results were loaded', async () => {
|
|
const records = Array.from({ length: 100 }, (_, index) => record(String(index), '문의'));
|
|
const repeated = pageContext(async () => ({ items: records }));
|
|
await assert.rejects(repeated.load(), /다음 페이지/);
|
|
assert.deepEqual(repeated.calls, [1, 2]);
|
|
const missing = pageContext(async (number) => ({ items: number === 1 ? records : [], meta: { total: 101 } }));
|
|
await assert.rejects(missing.load(), /끝까지/);
|
|
});
|
|
|
|
test('list form uses fuzzy results while category and ownership filters continue to apply', async () => {
|
|
const page = pageContext(async () => ({ items: [
|
|
record('mine', '로그인 오류'), record('other', '로그인 오류', { requester_id: 'someone-else' }),
|
|
record('improvement', '로그인 개선', { Category: 'IMPROVEMENT_QNA' }), record('unrelated', '결제 승인')
|
|
] }));
|
|
await page.init();
|
|
page.submit('로그임');
|
|
assert.match(page.html(), /data-id="mine"/);
|
|
assert.match(page.html(), /data-id="other"/);
|
|
assert.doesNotMatch(page.html(), /data-id="unrelated"/);
|
|
page.category.value = '일반문의';
|
|
page.category.listeners.change();
|
|
assert.doesNotMatch(page.html(), /data-id="improvement"/);
|
|
const mine = page.elements.get('#onlyMine');
|
|
mine.checked = true;
|
|
mine.listeners.change();
|
|
assert.match(page.html(), /data-id="mine"/);
|
|
assert.doesNotMatch(page.html(), /data-id="other"/);
|
|
page.submit('qzxv');
|
|
assert.equal(page.html(), '');
|
|
assert.equal(page.elements.get('#tableEmpty').style.display, 'block');
|
|
assert.equal(page.elements.get('#searchStatus').textContent, '검색 결과 0건');
|
|
});
|
|
|
|
test('older pages remain reachable and a new search resets pagination before showing a matching old post', async () => {
|
|
const records = Array.from({ length: 201 }, (_, index) => record(String(index + 1), index === 200 ? '고유마지막글' : '일반 문의 ' + index));
|
|
const page = pageContext(async (number) => ({ items: records.slice((number - 1) * 100, number * 100), meta: { total: 201 } }));
|
|
await page.init();
|
|
assert.deepEqual(page.calls, [1, 2, 3]);
|
|
assert.ok(page.buttonCount() <= 9);
|
|
page.clickPage(21);
|
|
assert.match(page.html(), /data-id="201"/);
|
|
page.submit('고유마지막글');
|
|
assert.match(page.html(), /data-id="201"/);
|
|
assert.match(page.elements.get('#pagination').innerHTML, /class="active" data-page="1"/);
|
|
page.submit('');
|
|
assert.match(page.html(), /data-id="1"/);
|
|
assert.doesNotMatch(page.html(), /data-id="201"/);
|
|
});
|
|
|
|
test('partial loading remains searchable and visibly reports a page fetch failure', async () => {
|
|
const page = pageContext(async (number) => {
|
|
if (number === 2) throw new Error('fixture connection failure');
|
|
return { items: [record('loaded', '로그인 오류')], meta: { totalPages: 2 } };
|
|
});
|
|
await page.init();
|
|
page.submit('로그임');
|
|
assert.match(page.html(), /data-id="loaded"/);
|
|
assert.match(page.elements.get('#searchStatus').textContent, /목록을 모두 불러오지 못했습니다/);
|
|
assert.equal(page.elements.get('#searchStatus').hidden, false);
|
|
assert.equal(page.warnings.length, 1);
|
|
assert.deepEqual(page.calls, [1, 2]);
|
|
});
|