Files

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