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
+
+ Original source
+
+
+
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");
+});