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