diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e7df80..6c288c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable Doc2FAQ changes are recorded here. Version numbers follow semantic versioning; entries describe shipped source functionality and do not imply an external AI service. +## [0.5.0] — Unreleased + +### Added +- Read-only source viewer highlighting the selected FAQ's exact original passages, with a stacked layout on narrow screens. +- Per-FAQ Needs review / Approved status, persisted in saved projects and optional browser autosave. +- Combined question/answer search and review-status filters, visible result counts, and Clear filters. + +### Changed +- Editing questions or answers and merging FAQs reset approval; incomplete FAQs cannot be approved. +- Project schema 2 stores review status and upgrades schema-1 projects with Needs review defaults. Older application versions reject schema-2 files instead of silently discarding approval state. +- Filters affect only the review list; saving, export preview, and all export formats retain every FAQ in draft order. +- Development version is 0.5.0-SNAPSHOT; v0.4.0 remains the published release. + ## 0.4.0 — 2026-10-29 ### Added diff --git a/README.md b/README.md index 0052f19..326eec0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ Created by **Okan Tiktas** · [OKAN-SOFTWARE](https://github.com/OKAN-SOFTWARE). > **Current version: v0.4.0 — Review, Save & Export.** Download the ready-to-run JAR or Windows ZIP from [GitHub Releases](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases/tag/v0.4.0). +## In development: v0.5.0 — Source & Review + +- **Source beside answer:** select **View source beside answer** on an FAQ to highlight all of its original passages in a read-only document viewer beside the review editor on desktop (stacked on smaller screens). References still use extracted-text lines, not PDF page numbers. Manual FAQs have no automatic reference. +- **Review status:** FAQs start as **Needs review**. Mark a complete answer **Approved** after checking it. Editing its question or answer, or merging FAQs, returns it to **Needs review**. Moving an item preserves approval; Undo restores the status from before a removal or merge. +- **Search and review filters:** combine a case-insensitive question/answer search with **All statuses**, **Needs review**, or **Approved**. The summary shows visible, approved, and pending counts. **Clear filters** restores the full list. Filters never change what is saved, previewed, or exported: all FAQs remain included, and incomplete entries still block export. +- **Saved work compatibility:** approval status is preserved in project downloads and optional autosave. New saves use project schema 2; v0.4 schema-1 files still open with FAQs marked Needs review. Schema-2 files require v0.5 or later to reopen. + +Development builds use `0.5.0-SNAPSHOT` in `pom.xml` (`target/doc2faq-0.5.0-SNAPSHOT.jar`). The latest published download remains v0.4.0 until a new release is validated and published. + ## New in v0.4.0 - **Save work / Open saved work:** a versioned JSON file preserves source text, title, mode, draft order, edits, original excerpts, and duplicate-review choices. Incomplete FAQs can be saved too. Project files are limited to 4 MB and validated before replacing the workspace. @@ -167,7 +176,7 @@ After updating your checkout to the latest `main`, run these commands from the d ```cmd mvn -B -ntp clean verify node --test src/test/js/*.test.cjs -java -jar target/doc2faq-0.4.0.jar +java -jar target/doc2faq-0.5.0-SNAPSHOT.jar ``` The JavaScript checks require Node.js. Keep the terminal open while the app runs, and use **Ctrl+C** to stop it. Open **http://localhost:8080** and refresh the browser to load the updated frontend. @@ -175,7 +184,7 @@ The JavaScript checks require Node.js. Keep the terminal open while the app runs If port 8080 is occupied, stop the previous Doc2FAQ process or use an alternate port: ```cmd -java -jar target/doc2faq-0.4.0.jar --server.port=8081 +java -jar target/doc2faq-0.5.0-SNAPSHOT.jar --server.port=8081 ``` Then open **http://localhost:8081**. Merging source changes does not update an existing JAR or a running Java process; rebuild and restart to use the fixes. Published JARs must be built from the corresponding release commit. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b0be0e9..4fdaf82 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,19 @@ The release version is **v0.4.0**. The release commit uses `0.4.0` in `pom.xml`; earlier development builds used `0.4.0-SNAPSHOT`. Merging source changes alone does not publish a GitHub Release or update an already downloaded JAR. +## v0.5.0 development checks + +The next development version is `0.5.0-SNAPSHOT`; use `target/doc2faq-0.5.0-SNAPSHOT.jar` when testing a source build. The v0.4.0 publishing instructions below describe the existing release; update the release version and notes before publishing v0.5.0. + +Before preparing v0.5.0: + +1. Select an FAQ's source viewer and check every highlighted passage, including multiple references after merge. Try a manual FAQ and a document containing HTML-like text. Check desktop side-by-side and mobile stacked layouts. +2. Approve a complete FAQ; edit its question or answer and confirm it needs review again. Confirm blank FAQs cannot be approved. Remove/Undo and reorder approved FAQs. +3. Combine search and status filters, edit a matching FAQ, check counts, and clear filters. Add an FAQ while filtering and confirm it is visible. +4. Save/reopen and autosave/reload approvals; import a schema-1 project and confirm Needs review defaults. Reject malformed review states without replacing the workspace. +5. With filters active, preview and export all five formats. Confirm all FAQs remain included in draft order; a hidden blank entry must still prevent export. +6. Run the Java and JavaScript checks below and smoke-test the packaged JAR. + ## Validate the release build Use JDK 21, Maven 3.6.3+, and Node.js: diff --git a/pom.xml b/pom.xml index 4ef5c5a..2dcadeb 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.okansoftware doc2faq - 0.4.0 + 0.5.0-SNAPSHOT Doc2FAQ Privacy-conscious document-to-FAQ preview workbench diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 64f3af6..db98f74 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -53,6 +53,9 @@ let undoHistory = []; let saveTimer; let importRevision = 0; let reviewUpdaters = []; +let filterUpdaters = []; +let activeSourceId = null; +let sourceViewKey = null; let activeDraftRequest = 0; let activeExportRequest = 0; let exporting = false; @@ -60,7 +63,7 @@ let exporting = false; function projectTitle() { return $("project-title").value.trim() || "FAQ draft"; } function currentProject() { - return {type: "doc2faq-project", schemaVersion: 1, title: projectTitle(), mode: selectedMode(), + return {type: "doc2faq-project", schemaVersion: 2, title: projectTitle(), mode: selectedMode(), downloadName: $("download-name").value, source: {text: source.value, name: sourceName}, items}; } @@ -98,7 +101,7 @@ function applyProject(project) { modeSelect.value = project.mode; items = project.items; undoHistory = []; - search.value = ""; + resetReviewView(); updateModeText(); updateCount(); render(); autosave(); } @@ -150,6 +153,8 @@ function updateControls() { const hasItems = items.length > 0; const ready = validDraft(); search.disabled = !hasItems; + $("review-filter").disabled = !hasItems; + $("clear-filters-btn").disabled = !search.value && !["needs-review", "approved"].includes($("review-filter").value); copyButton.disabled = !ready || ["pdf", "docx"].includes(formatSelect.value); downloadButton.disabled = !ready || exporting; formatSelect.disabled = !hasItems; @@ -184,35 +189,95 @@ function move(item, offset) { render(); autosave(); } -function render() { - reviewUpdaters = []; +function resetReviewView() { + search.value = ""; + $("review-filter").value = "all"; + activeSourceId = null; +} + +function matchesReview(item) { const term = search.value.trim().toLocaleLowerCase(); - const shown = items.filter((item) => - (item.question + " " + item.answer).toLocaleLowerCase().includes(term)); - results.replaceChildren(); + const filter = $("review-filter").value; + return (item.question + " " + item.answer).toLocaleLowerCase().includes(term) && + (!["approved", "needs-review"].includes(filter) || item.reviewStatus === filter); +} + +function updateReviewSummary() { + const approved = items.filter((item) => item.reviewStatus === "approved").length; + const shown = items.filter(matchesReview).length; + $("review-summary").textContent = shown + " of " + items.length + " FAQs shown · " + + approved + " approved · " + (items.length - approved) + " need review"; + $("no-matches").hidden = shown > 0; + for (const update of filterUpdaters) update(); updateControls(); + renderSourceViewer(); +} - if (!shown.length) { - const empty = document.createElement("div"); - empty.className = "empty-state"; - const icon = document.createElement("div"); - icon.className = "empty-icon"; - icon.textContent = "?"; - const title = document.createElement("h4"); - title.textContent = items.length ? "No matching answers." : "Your answers will appear here."; - const hint = document.createElement("p"); - hint.textContent = items.length - ? "Try a different search term." - : "Load the example or add text on the left, then create your first draft."; - empty.append(icon, title, hint); - results.append(empty); +function renderSourceViewer(revealSelection = false) { + const item = items.find((entry) => entry.id === activeSourceId && matchesReview(entry)); + const viewer = $("source-viewer"); + const content = $("source-viewer-content"); + viewer.hidden = !item; + if (!item) { + content.replaceChildren(); + sourceViewKey = null; + return; + } + $("source-viewer-title").textContent = "Source for FAQ " + (items.indexOf(item) + 1); + const key = JSON.stringify([item.id, source.value, item.sources]); + if (key === sourceViewKey) { + if (revealSelection) content.querySelector(".source-line-selected")?.scrollIntoView?.({block: "nearest"}); return; } + sourceViewKey = key; + content.replaceChildren(); + $("source-viewer-note").textContent = item.sources.length + ? "Highlighted passages are the original source. Compare them with your edited answer. References use extracted-text lines, not PDF pages." + : "This FAQ was added manually and has no automatic source reference."; + const sourceLines = source.value.split(/\r\n|[\n\r\v\f\u0085\u2028\u2029]/); + const selected = new Set(); + for (const ref of item.sources) { + if (Number.isInteger(ref.startLine) && Number.isInteger(ref.endLine) && ref.startLine >= 1 && + ref.endLine >= ref.startLine && ref.endLine <= sourceLines.length && + sourceLines.slice(ref.startLine - 1, ref.endLine).join("\n") === ref.excerpt) { + for (let line = ref.startLine; line <= ref.endLine; line++) selected.add(line); + } + } + if (item.sources.length && !selected.size) { + $("source-viewer-note").textContent = "The saved references do not match this document. Check the source manually."; + } + for (const [index, text] of sourceLines.entries()) { + const row = document.createElement("div"); + row.className = selected.has(index + 1) ? "source-line source-line-selected" : "source-line"; + const label = document.createElement("span"); + label.className = "source-line-number"; + label.textContent = String(index + 1); + const passage = document.createElement(selected.has(index + 1) ? "mark" : "span"); + passage.textContent = text || " "; + row.append(label, passage); content.append(row); + } + if (revealSelection) content.querySelector(".source-line-selected")?.scrollIntoView?.({block: "nearest"}); +} + +function render() { + reviewUpdaters = []; + filterUpdaters = []; + results.replaceChildren(); + const empty = document.createElement("div"); + empty.id = "no-matches"; + empty.className = "empty-state"; + const title = document.createElement("h4"); + title.textContent = items.length ? "No matching answers." : "Your answers will appear here."; + const hint = document.createElement("p"); + hint.textContent = items.length ? "Try another search or clear the review filters." : + "Load the example or add text on the left, then create your first draft."; + empty.append(title, hint); results.append(empty); - for (const item of shown) { + for (const item of items) { const index = items.indexOf(item); const card = document.createElement("section"); card.className = "faq-editor"; + filterUpdaters.push(() => { card.hidden = !matchesReview(item); }); const heading = document.createElement("div"); heading.className = "faq-editor-heading"; @@ -248,6 +313,8 @@ function render() { draftRevision++; undoHistory = []; item.question = question.value; + item.reviewStatus = "needs-review"; + updateReviewStatus(); updateReviewSummary(); updateControls(); updateBadge(); for (const update of reviewUpdaters) update(); autosave(); @@ -267,10 +334,39 @@ function render() { draftRevision++; undoHistory = []; item.answer = answer.value; + item.reviewStatus = "needs-review"; + updateReviewStatus(); updateReviewSummary(); updateControls(); updateBadge(); autosave(); }); const reviewDetails = document.createElement("div"); + const reviewLabel = document.createElement("label"); + reviewLabel.className = "field-label"; + reviewLabel.htmlFor = "faq-review-" + index; + reviewLabel.textContent = "REVIEW STATUS"; + const reviewSelect = document.createElement("select"); + reviewSelect.id = reviewLabel.htmlFor; + reviewSelect.className = "review-status-select"; + for (const [value, text] of [["needs-review", "Needs review"], ["approved", "Approved"]]) { + const option = document.createElement("option"); + option.value = value; option.textContent = text; reviewSelect.append(option); + } + function updateReviewStatus() { reviewSelect.value = item.reviewStatus; } + updateReviewStatus(); + reviewSelect.addEventListener("change", () => { + if (reviewSelect.value === "approved" && (!item.question.trim() || !item.answer.trim())) { + updateReviewStatus(); + setStatus("Complete the question and answer before approving this FAQ.", "error"); return; + } + draftRevision++; undoHistory = []; + item.reviewStatus = reviewSelect.value === "approved" ? "approved" : "needs-review"; + updateReviewSummary(); autosave(); + }); + const viewButton = makeButton("View source beside answer", "editor-action", () => { + activeSourceId = item.id; renderSourceViewer(true); + $("source-viewer").scrollIntoView?.({block: "nearest"}); + }); + reviewDetails.append(reviewLabel, reviewSelect, viewButton); const badge = document.createElement("p"); badge.className = "source-badge"; function updateBadge() { @@ -332,6 +428,7 @@ function render() { card.append(heading, questionLabel, question, answerLabel, answer, reviewDetails); results.append(card); } + updateReviewSummary(); } function markdown() { @@ -394,7 +491,7 @@ function clearDraft() { updateModeText(); items = []; undoHistory = []; - search.value = ""; + resetReviewView(); render(); } @@ -405,7 +502,12 @@ source.addEventListener("input", () => { clearDraft(); autosave(); setStatus("Source updated. Create a new FAQ draft."); }); -search.addEventListener("input", render); +search.addEventListener("input", updateReviewSummary); +$("review-filter").addEventListener("change", updateReviewSummary); +$("clear-filters-btn").addEventListener("click", () => { + search.value = ""; $("review-filter").value = "all"; updateReviewSummary(); +}); +$("close-source-btn").addEventListener("click", () => { activeSourceId = null; renderSourceViewer(); }); modeSelect.addEventListener("change", () => { updateModeText(); clearDraft(); autosave(); @@ -428,7 +530,7 @@ $("sample-btn").addEventListener("click", () => { addButton.addEventListener("click", () => { if (items.length >= MAX_ITEMS) return; - search.value = ""; + resetReviewView(); draftRevision++; undoHistory = []; items.push(projectTools.normalizeItem({question: "", answer: ""})); @@ -540,7 +642,7 @@ draftButton.addEventListener("click", async () => { .slice(0, MAX_ITEMS).map(projectTools.normalizeItem) : []; undoHistory = []; - search.value = ""; + resetReviewView(); render(); autosave(); setStatus(items.length ? "Created " + items.length + " FAQ item(s). Edit and verify before exporting." diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index d9e3466..63391d0 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -78,6 +78,11 @@

Turn long documents into clear answers.

No document loaded

+
0 / 50,000 charactersUTF-8 text
@@ -98,8 +103,8 @@

Turn long documents into clear answers.

Edit this name before downloading. The extension follows your chosen format.

- - + + + +
+

+

Search and filters change this view. Exports always include all FAQs.

diff --git a/src/main/resources/static/project.js b/src/main/resources/static/project.js index d43ba87..4267658 100644 --- a/src/main/resources/static/project.js +++ b/src/main/resources/static/project.js @@ -16,7 +16,8 @@ originalQuestion: item.originalQuestion ?? item.question, originalAnswer: item.originalAnswer ?? item.answer, sources: item.sources || (item.source ? [item.source] : []), - keptWith: item.keptWith || [], edited: item.edited === true + keptWith: item.keptWith || [], edited: item.edited === true, + reviewStatus: item.reviewStatus === "approved" ? "approved" : "needs-review" }; } function edited(item) { @@ -38,8 +39,8 @@ return (stem || "document-Doc2FAQ") + "." + ext; } function validateProject(project) { - if (!project || project.type !== "doc2faq-project" || project.schemaVersion !== 1) { - fail("Choose a supported Doc2FAQ project file (schema version 1)."); + if (!project || project.type !== "doc2faq-project" || ![1, 2].includes(project.schemaVersion)) { + fail("Choose a supported Doc2FAQ project file (schema version 1 or 2)."); } const title = string(project.title, 200, "project title"); if (!["extract", "generate"].includes(project.mode)) fail("Invalid project mode."); @@ -75,12 +76,15 @@ }); if (!Array.isArray(raw.keptWith) || raw.keptWith.length > 30 || raw.keptWith.some((value) => typeof value !== "string" || value.length > 100)) fail("Invalid review choices."); - return {id: itemId, question, answer, originalQuestion, originalAnswer, sources, + const reviewStatus = raw.reviewStatus ?? "needs-review"; + if (!["needs-review", "approved"].includes(reviewStatus)) fail("Invalid FAQ review status."); + if (reviewStatus === "approved" && (!question.trim() || !answer.trim())) fail("Complete an FAQ before approving it."); + return {id: itemId, question, answer, originalQuestion, originalAnswer, sources, reviewStatus, keptWith: [...raw.keptWith], edited: raw.edited === true}; }); if (answerCharacters > 300000) fail("FAQ text must not exceed 300,000 characters."); for (const item of items) item.keptWith = item.keptWith.filter((key) => ids.has(key) && key !== item.id); - return {type: "doc2faq-project", schemaVersion: 1, title, downloadName, mode: project.mode, source: {text, name}, items}; + return {type: "doc2faq-project", schemaVersion: 2, title, downloadName, mode: project.mode, source: {text, name}, items}; } function serialize(project) { const text = JSON.stringify(validateProject(project), null, 2) + "\n"; @@ -119,7 +123,7 @@ saved.excerpt === source.excerpt)) sources.push({...source}); } if (sources.length > 30) fail("The merged FAQ would have too many source references."); - return {...first, answer, sources, keptWith: [], edited: true}; + return {...first, answer, sources, keptWith: [], edited: true, reviewStatus: "needs-review"}; } const api = {MAX_PROJECT_BYTES, normalizeItem, edited, sourceTitle, defaultDownloadName, downloadFilename, validateProject, serialize, parse, similar, related, merge}; diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css index 878a684..9d69805 100644 --- a/src/main/resources/static/styles.css +++ b/src/main/resources/static/styles.css @@ -85,3 +85,20 @@ body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;bac .download-name-field{display:flex;align-items:center;border:1px solid #385066;background:#0c1829;border-radius:8px;overflow:hidden}.download-name-field input{min-width:0;flex:1;width:100%;border:0;padding:11px 12px;background:transparent;color:var(--text);font-size:13px}.download-name-field span{padding:10px 12px;color:var(--mint);font:12px ui-monospace,monospace;border-left:1px solid var(--line)} #download-name-help{margin-bottom:16px}#preview-content h1{text-align:center} @media(prefers-reduced-motion:reduce){body::before{background:radial-gradient(ellipse at 65% 15%,#4a9e9315,transparent 70%)}#ambient-background{opacity:.4}} + +/* Review workflow: preserve the existing workspace at narrow widths. */ +[hidden]{display:none!important} +.review-filters{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0} +.review-filters .field-label{margin:0} +.review-filters select,.review-status-select{background:#0c1829;color:var(--text);border:1px solid #385066;border-radius:8px;padding:10px;max-width:100%} +.review-status-select{margin:4px 10px 12px 0} +.source-viewer{margin-top:18px;border:1px solid #426b78;border-radius:10px;padding:14px;position:sticky;top:16px;background:#101f30} +.source-viewer-heading{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap} +.source-viewer-heading h4{margin:0;font-size:15px} +.source-viewer-content{max-height:440px;overflow:auto;font:12px/1.7 ui-monospace,monospace;background:#0c1829;border-radius:6px;padding:10px 0} +.source-line{display:flex;gap:12px;padding:2px 10px;white-space:pre-wrap;overflow-wrap:anywhere} +.source-line-number{flex:0 0 36px;color:#8da8ba;text-align:right;user-select:none} +.source-line>span:last-child,.source-line>mark{min-width:0;flex:1} +.source-line-selected{background:#245c4b55;border-left:3px solid var(--mint);padding-left:7px} +.source-line mark{background:transparent;color:#bcffe0} +@media(max-width:950px){.source-viewer{position:static}.source-viewer-content{max-height:280px}} diff --git a/src/test/js/app-state.test.cjs b/src/test/js/app-state.test.cjs index 76a3855..12a2b3c 100644 --- a/src/test/js/app-state.test.cjs +++ b/src/test/js/app-state.test.cjs @@ -227,3 +227,104 @@ test("clearing during a PDF export prevents an old document download", async () assert.equal(app.element("faq-count").textContent, "0 ITEMS"); assert.match(app.element("status").textContent, /cleared/); }); + + +test("approvals persist through autosave and reset when an answer or question changes", async () => { + const app = harness(); await makeDraft(app); + app.element("autosave-toggle").checked = true; app.element("autosave-toggle").handlers.change(); + let select = descendants(app.element("results")).find(node => node.className === "review-status-select"); + select.value = "approved"; select.handlers.change(); + const restored = harness(app.saved); + assert.match(restored.element("review-summary").textContent, /1 approved/); + const answer = descendants(restored.element("results")).find(node => node.tag === "textarea"); + answer.value = "Updated answer."; answer.handlers.input(); + assert.match(restored.element("review-summary").textContent, /0 approved/); + select = descendants(restored.element("results")).find(node => node.className === "review-status-select"); + select.value = "approved"; select.handlers.change(); + const question = descendants(restored.element("results")).find(node => node.className === "faq-edit-question"); + question.value = "New question?"; question.handlers.input(); + assert.equal(select.value, "needs-review"); + assert.equal(JSON.parse(restored.saved.get("doc2faq.project.v1")).items[0].reviewStatus, "needs-review"); +}); + +test("search and review filters combine without changing exports or losing typed edits", async () => { + const app = harness(); await makeDraft(app); + app.element("add-item-btn").handlers.click(); + const cards = descendants(app.element("results")).filter(node => node.className === "faq-editor"); + const select = descendants(cards[0]).find(node => node.className === "review-status-select"); + select.value = "approved"; select.handlers.change(); + app.element("review-filter").value = "approved"; app.element("review-filter").handlers.change(); + assert.equal(cards[0].hidden, false); assert.equal(cards[1].hidden, true); + app.element("search-input").value = "TEAM"; app.element("search-input").handlers.input(); + assert.match(app.element("review-summary").textContent, /1 of 2/); + const answer = descendants(cards[0]).find(node => node.tag === "textarea"); + answer.value = "New answer without the search term."; answer.handlers.input(); + assert.equal(cards[0].hidden, true); + app.element("clear-filters-btn").handlers.click(); + assert.equal(cards[0].hidden, false); assert.equal(cards[1].hidden, false); + assert.equal(answer.value, "New answer without the search term."); + // The incomplete hidden FAQ still blocks export; filters never remove it from the draft. + assert.equal(app.element("download-btn").disabled, true); + assert.equal(app.element("faq-count").textContent, "2 ITEMS"); +}); + +test("blank FAQs cannot be approved and new entries remain visible after filtering", async () => { + const app = harness(); await makeDraft(app); + app.element("review-filter").value = "approved"; app.element("review-filter").handlers.change(); + app.element("add-item-btn").handlers.click(); + assert.equal(app.element("review-filter").value, "all"); + const selects = descendants(app.element("results")).filter(node => node.className === "review-status-select"); + selects[1].value = "approved"; selects[1].handlers.change(); + assert.equal(selects[1].value, "needs-review"); + assert.match(app.element("status").textContent, /Complete/); +}); + +test("source viewer highlights exact passages and closes when the selected FAQ disappears", async () => { + const app = harness(); await makeDraft(app); + descendants(app.element("results")).find(node => node.textContent === "View source beside answer").handlers.click(); + assert.equal(app.element("source-viewer").hidden, false); + const highlighted = descendants(app.element("source-viewer-content")).filter(node => node.tag === "mark"); + assert.equal(highlighted.length, 2); + assert.equal(highlighted[1].textContent, "A: Ask the team."); + app.element("search-input").value = "No match"; app.element("search-input").handlers.input(); + assert.equal(app.element("source-viewer").hidden, true); + app.element("clear-filters-btn").handlers.click(); + assert.equal(app.element("source-viewer").hidden, false); + app.element("clear-document-btn").handlers.click(); + assert.equal(app.element("source-viewer").hidden, true); + assert.equal(app.element("source-viewer-content").children.length, 0); +}); + +test("manual FAQs show an explicit no-reference notice in the source viewer", async () => { + const app = harness(); app.element("add-item-btn").handlers.click(); + descendants(app.element("results")).find(node => node.textContent === "View source beside answer").handlers.click(); + assert.match(app.element("source-viewer-note").textContent, /no automatic source reference/); + assert.equal(descendants(app.element("source-viewer-content")).filter(node => node.tag === "mark").length, 0); +}); + +test("editing FAQs preserves source viewer nodes and scroll; only explicit selection scrolls passages", async () => { + const app = harness(); await makeDraft(app); + const content = app.element("source-viewer-content"); + let passageScrolls = 0; + content.querySelector = () => ({scrollIntoView() { passageScrolls++; }}); + const open = descendants(app.element("results")).find(node => node.textContent === "View source beside answer"); + open.handlers.click(); + assert.equal(passageScrolls, 1); + const originalRows = [...content.children]; + content.scrollTop = 125; + for (const className of ["faq-edit-answer", "faq-edit-question"]) { + const field = descendants(app.element("results")).find(node => node.className === className); + field.value = "Updated text"; field.handlers.input(); + assert.equal(content.children[0], originalRows[0]); + assert.equal(content.scrollTop, 125); + assert.equal(passageScrolls, 1); + } + const select = descendants(app.element("results")).find(node => node.className === "review-status-select"); + select.value = "approved"; select.handlers.change(); + assert.equal(content.children[0], originalRows[0]); + assert.equal(passageScrolls, 1); + open.handlers.click(); + assert.equal(passageScrolls, 2); + app.element("close-source-btn").handlers.click(); + assert.equal(content.children.length, 0); +}); diff --git a/src/test/js/project.test.cjs b/src/test/js/project.test.cjs index b41aa65..42e2e5f 100644 --- a/src/test/js/project.test.cjs +++ b/src/test/js/project.test.cjs @@ -8,7 +8,7 @@ function fixture() { source: {section: "Services", startLine: 1, endLine: 2, excerpt: "## Services\nOnboarding guides."}}); const second = project.normalizeItem({question: "Where is support?", answer: "Ask the team.", source: {section: "Support", startLine: 3, endLine: 4, excerpt: "## Support\nAsk the team."}}); - return {type: "doc2faq-project", schemaVersion: 1, title: "Team FAQ", downloadName: "team-Doc2FAQ", mode: "generate", + return {type: "doc2faq-project", schemaVersion: 2, title: "Team FAQ", downloadName: "team-Doc2FAQ", mode: "generate", source: {text, name: "team.md"}, items: [first, second]}; } @@ -27,7 +27,7 @@ test("project round trip preserves source, order, incomplete edits and original test("rejects malformed, oversized and unsupported project files", () => { assert.throws(() => project.parse("{"), /valid JSON/); assert.throws(() => project.parse(" ".repeat(project.MAX_PROJECT_BYTES + 1)), /4 MB/); - assert.throws(() => project.parse(JSON.stringify({...fixture(), schemaVersion: 2})), /supported/); + assert.throws(() => project.parse(JSON.stringify({...fixture(), schemaVersion: 99})), /supported/); const data = fixture(); data.items[0].answer = "x".repeat(50001); assert.throws(() => project.serialize(data), /answer/); }); @@ -101,3 +101,27 @@ test("older saved work remains readable and custom filenames survive save and re data.downloadName = "Client knowledge base"; assert.equal(project.parse(project.serialize(data)).downloadName, "Client knowledge base"); }); + + +test("v0.4 projects upgrade to schema 2 with all FAQs needing review", () => { + const data = fixture(); data.schemaVersion = 1; + for (const item of data.items) delete item.reviewStatus; + const restored = project.parse(JSON.stringify(data)); + assert.equal(restored.schemaVersion, 2); + assert.ok(restored.items.every(item => item.reviewStatus === "needs-review")); + assert.equal(data.items[0].reviewStatus, undefined); +}); + +test("review approvals survive saved projects, with invalid states rejected", () => { + const data = fixture(); data.items[0].reviewStatus = "approved"; + assert.equal(project.parse(project.serialize(data)).items[0].reviewStatus, "approved"); + data.items[0].reviewStatus = "unknown"; + assert.throws(() => project.serialize(data), /review status/); + data.items[0].reviewStatus = "approved"; data.items[0].answer = " "; + assert.throws(() => project.serialize(data), /Complete an FAQ/); +}); + +test("merging approved FAQs returns the combined answer to needs review", () => { + const data = fixture(); data.items.forEach(item => item.reviewStatus = "approved"); + assert.equal(project.merge(...data.items).reviewStatus, "needs-review"); +});