Improve audit pipeline and outreach review
This commit is contained in:
@@ -227,6 +227,124 @@ test("buildAuditEvidenceInput preserves screenshot references without base64 pay
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput creates stable evidence ledger refs for source facts", () => {
|
||||
const first = buildAuditEvidenceInput({
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
pageKind: "homepage",
|
||||
title: "Startseite",
|
||||
metaDescription: "Bäckerei Muster in Berlin",
|
||||
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
usesHttps: true,
|
||||
missingMetaDescription: false,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 0,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-home-mobile",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_001_000,
|
||||
},
|
||||
],
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
externalMarkdown:
|
||||
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
const second = buildAuditEvidenceInput({
|
||||
...first,
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
pageKind: "homepage",
|
||||
title: "Startseite",
|
||||
metaDescription: "Bäckerei Muster in Berlin",
|
||||
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
usesHttps: true,
|
||||
missingMetaDescription: false,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 0,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-home-mobile",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_001_000,
|
||||
},
|
||||
],
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
externalMarkdown:
|
||||
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
|
||||
});
|
||||
|
||||
assert.deepEqual(first.evidenceLedger, second.evidenceLedger);
|
||||
const evidenceTypes = new Set(first.evidenceLedger.map((entry) => entry.type));
|
||||
for (const type of [
|
||||
"crawl_page",
|
||||
"technical_check",
|
||||
"screenshot",
|
||||
"pagespeed",
|
||||
"jina_excerpt",
|
||||
] as const) {
|
||||
assert.equal(evidenceTypes.has(type), true, `${type} evidence should exist.`);
|
||||
}
|
||||
assert.equal(
|
||||
first.evidenceLedger.every((entry) => entry.id.includes("unknown") === false),
|
||||
true,
|
||||
"Evidence IDs should be stable source refs, not unknown placeholders.",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
pageSpeedInputs: [
|
||||
@@ -421,15 +539,16 @@ test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", ()
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
assert.deepEqual(actual.selectedSkills.map((skill) => skill.id), [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
"mobile-usability",
|
||||
"conversion-copy",
|
||||
]);
|
||||
assert.equal(actual.selectedSkills.length, 6);
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
@@ -475,6 +594,7 @@ test("buildAuditEvidenceInput gates v3 skills when declared inputs are missing",
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"first-impression-clarity",
|
||||
"contact-conversion",
|
||||
"mobile-usability",
|
||||
|
||||
@@ -5,6 +5,15 @@ import test from "node:test";
|
||||
|
||||
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
||||
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
|
||||
const toneGuidelinesPath = path.join(
|
||||
process.cwd(),
|
||||
"lib",
|
||||
"ai",
|
||||
"customer-tone-guidelines.ts",
|
||||
);
|
||||
const toneGuidelinesSource = existsSync(toneGuidelinesPath)
|
||||
? readFileSync(toneGuidelinesPath, "utf8")
|
||||
: "";
|
||||
const generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
|
||||
const generationSource = existsSync(generationSourcePath)
|
||||
? readFileSync(generationSourcePath, "utf8")
|
||||
@@ -129,6 +138,12 @@ test("action starts, queries evidence, and runs stage pipeline", () => {
|
||||
test("action includes all required audit stages", () => {
|
||||
for (const stage of [
|
||||
"classification",
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
@@ -142,6 +157,159 @@ test("action includes all required audit stages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("specialist fan-out runs after evidence input and before German copy", () => {
|
||||
const evidenceInputIndex = actionSource.indexOf("const evidenceInput = buildAuditEvidenceInput");
|
||||
const fanOutIndex = actionSource.indexOf("Promise.all(\n specialistStageConfigs.map");
|
||||
const verifierIndex = actionSource.indexOf('currentStep = "evidenceVerifier"');
|
||||
const germanCopyIndex = actionSource.indexOf('currentStep = "germanCopy"');
|
||||
|
||||
assert.notEqual(evidenceInputIndex, -1, "Action should build evidence input.");
|
||||
assert.notEqual(germanCopyIndex, -1, "Action should still run German copy.");
|
||||
assert.notEqual(fanOutIndex, -1, "Action should fan out specialist stage configs.");
|
||||
assert.notEqual(verifierIndex, -1, "Action should run the evidence verifier.");
|
||||
assert.equal(
|
||||
fanOutIndex > evidenceInputIndex && fanOutIndex < germanCopyIndex,
|
||||
true,
|
||||
"Specialist fan-out should run after evidence input and before German copy.",
|
||||
);
|
||||
assert.equal(
|
||||
verifierIndex > fanOutIndex && verifierIndex < germanCopyIndex,
|
||||
true,
|
||||
"Evidence verifier should run after specialist fan-out and before German copy.",
|
||||
);
|
||||
});
|
||||
|
||||
test("specialist stages use specialist schemas and verified findings feed German copy", () => {
|
||||
assert.equal(
|
||||
hasStageCall("auditSpecialistResultSchema"),
|
||||
true,
|
||||
"Specialist stages should call generateObject with auditSpecialistResultSchema.",
|
||||
);
|
||||
assert.equal(
|
||||
hasStageCall("auditEvidenceVerificationSchema"),
|
||||
true,
|
||||
"Verifier stage should call generateObject with auditEvidenceVerificationSchema.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/(?:const|let)\s+verifiedFindings\s*[:=]/,
|
||||
"Action should derive verifiedFindings before synthesis.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/verifiedResult?\.?object|verifiedFindingIds/,
|
||||
"Verifier output should use compact finding IDs instead of echoing full findings.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/verifiedFindingIds\.has\(candidate\.findingId\)/,
|
||||
"Action should map verifier-approved IDs back to original specialist findings.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\(\s*verifiedFindingsText/,
|
||||
"German copy should be generated from verified findings text.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\(\s*classificationSummary\s*,/,
|
||||
"German copy should no longer use raw classification summary as its primary finding input.",
|
||||
);
|
||||
});
|
||||
|
||||
test("critique specialist translates impeccable critique guidance into the audit fan-out", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/stage:\s*["']critiqueSpecialist["']/,
|
||||
"Action should include a dedicated critique specialist stage.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/impeccable-critique/,
|
||||
"Critique specialist should anchor findings to the impeccable critique skill id.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/kognitive Last|Nielsen|AI-Slop|Informationsarchitektur/,
|
||||
"Critique specialist should include critique guidance beyond generic visual trust.",
|
||||
);
|
||||
});
|
||||
|
||||
test("German copy prompt uses first-contact email tone guidelines without a new AI stage", () => {
|
||||
const buildPromptSource = extractFunctionSource("buildGermanCopyPrompt");
|
||||
|
||||
assert.doesNotMatch(
|
||||
buildPromptSource,
|
||||
/Ich-Ich Kontext/,
|
||||
"German copy prompt should not force formulaic Ich-Ich copy.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildCustomerTonePromptSection/,
|
||||
"German copy prompt should inject shared customer tone guidelines.",
|
||||
);
|
||||
assert.match(
|
||||
buildPromptSource,
|
||||
/evidence:\s*AuditEvidence/,
|
||||
"German copy prompt should accept explicit evidence context.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\([\s\S]*verifiedFindingsText[\s\S]*multimodalSummary[\s\S]*evidenceInput[\s\S]*\)/,
|
||||
"German copy prompt should receive the explicit evidence context at the callsite.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/kollegial direkt/,
|
||||
"Tone guidelines should lock the selected sender posture.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/maximal zwei verifizierte Befunde|max\. zwei verifizierte Befunde/,
|
||||
"Tone guidelines should keep outreach emails to at most two verified findings.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/kein Mini-Audit/,
|
||||
"Tone guidelines should explicitly forbid mini-audit emails.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/tone(?:Review|Rewrite|Specialist)|emailToneSpecialist|copyToneSpecialist/,
|
||||
"Tone work should not add another model-backed generation stage.",
|
||||
);
|
||||
});
|
||||
|
||||
test("quality review blocks when model review or German copy guard fails", () => {
|
||||
const qualityPromptSource = extractFunctionSource("buildQualityReviewPrompt");
|
||||
|
||||
assert.match(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
"qualityPassed should require both model review validity and German copy guard.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
||||
"qualityPassed must not ignore the model quality review.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/echte Erstmail von Matthias/,
|
||||
"Quality review should apply the selected first-contact email rubric.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/KI-Verkaufstext/,
|
||||
"Quality review should reject AI-like sales copy.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/verified findings|verifizierte Befunde/i,
|
||||
"Quality review should keep concrete claims tied to verified findings.",
|
||||
);
|
||||
});
|
||||
|
||||
test("action handles post-start failure paths in action-level catch", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
@@ -377,18 +545,18 @@ test("action runs german copy guard and blocks outreach-ready on validation fail
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*guardResult\.passed/,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
),
|
||||
true,
|
||||
"Only deterministic German copy guard failures should hard-block the audit run.",
|
||||
"Model QA and deterministic German copy guard failures should hard-block the audit run.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
||||
),
|
||||
false,
|
||||
"Subjective model QA warnings should not be combined with guardResult for terminal failure.",
|
||||
"Action must not ignore the model QA validity flag.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
|
||||
|
||||
@@ -96,6 +96,7 @@ test("auditGeneration module exports required mutation contracts", () => {
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"replaceAuditFindings",
|
||||
"finishAuditGenerationRun",
|
||||
];
|
||||
|
||||
@@ -113,6 +114,7 @@ test("auditGeneration module registers internalMutation contracts", () => {
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"replaceAuditFindings",
|
||||
"finishAuditGenerationRun",
|
||||
]) {
|
||||
assert.equal(
|
||||
@@ -126,6 +128,47 @@ test("auditGeneration module registers internalMutation contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("replaceAuditFindings replaces persisted audit findings with evidence refs", () => {
|
||||
const replaceSource = extractExportSource("replaceAuditFindings");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /query\("auditFindings"\)/),
|
||||
true,
|
||||
"replaceAuditFindings should query auditFindings.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /withIndex\("by_auditId"/),
|
||||
true,
|
||||
"replaceAuditFindings should query existing findings by auditId.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /ctx\.db\.delete\(/),
|
||||
true,
|
||||
"replaceAuditFindings should delete stale findings before inserting replacements.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /ctx\.db\.insert\(\s*"auditFindings"/),
|
||||
true,
|
||||
"replaceAuditFindings should insert into auditFindings.",
|
||||
);
|
||||
for (const field of [
|
||||
"skillId",
|
||||
"claim",
|
||||
"recommendation",
|
||||
"customerBenefit",
|
||||
"severity",
|
||||
"confidence",
|
||||
"evidenceRefs",
|
||||
"reviewStatus",
|
||||
]) {
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, new RegExp(`${field}:\\s*finding\\.${field}`)),
|
||||
true,
|
||||
`replaceAuditFindings should persist ${field}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
|
||||
const queueSource = extractExportSource("queueLeadAuditGeneration");
|
||||
|
||||
|
||||
@@ -202,3 +202,90 @@ test("audit-generation validators are declared", () => {
|
||||
"auditGenerationStage should include qualityReview.",
|
||||
);
|
||||
});
|
||||
|
||||
test("auditFindings table stores verified specialist findings with evidence refs", () => {
|
||||
const { section, objectBlock } = extractTableSection("auditFindings");
|
||||
|
||||
assertHas(
|
||||
/auditId:\s*v\.id\(["']audits["']\)/,
|
||||
objectBlock,
|
||||
"auditFindings.auditId must be required audit id.",
|
||||
);
|
||||
assertHas(
|
||||
/runId:\s*v\.id\(["']agentRuns["']\)/,
|
||||
objectBlock,
|
||||
"auditFindings.runId must be required run id.",
|
||||
);
|
||||
assertHas(
|
||||
/skillId:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.skillId must identify the source specialist skill.",
|
||||
);
|
||||
assertHas(
|
||||
/claim:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.claim must store the verified claim.",
|
||||
);
|
||||
assertHas(
|
||||
/recommendation:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.recommendation must store the concrete fix.",
|
||||
);
|
||||
assertHas(
|
||||
/customerBenefit:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.customerBenefit must store customer-facing impact.",
|
||||
);
|
||||
assertHas(
|
||||
/severity:\s*v\.union\(\s*v\.literal\(1\),\s*v\.literal\(2\),\s*v\.literal\(3\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditFindings.severity should be a 1-3 literal union.",
|
||||
);
|
||||
assertHas(
|
||||
/confidence:\s*v\.number\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.confidence must be persisted for review calibration.",
|
||||
);
|
||||
assertHas(
|
||||
/evidenceRefs:\s*v\.array\(\s*auditFindingEvidenceRef\s*\)/,
|
||||
objectBlock,
|
||||
"auditFindings.evidenceRefs must persist typed evidence refs.",
|
||||
);
|
||||
assertHas(
|
||||
/reviewStatus:\s*auditFindingReviewStatus/,
|
||||
objectBlock,
|
||||
"auditFindings.reviewStatus should use a review-status validator.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_auditId",\s*\["auditId"\]\)/,
|
||||
section,
|
||||
"auditFindings should have by_auditId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_runId",\s*\["runId"\]\)/,
|
||||
section,
|
||||
"auditFindings should have by_runId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_auditId_and_reviewStatus",\s*\["auditId",\s*"reviewStatus"\]\)/,
|
||||
section,
|
||||
"auditFindings should support review-status filtering per audit.",
|
||||
);
|
||||
});
|
||||
|
||||
test("specialist fan-out audit stages are declared in domain", () => {
|
||||
for (const stage of [
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
]) {
|
||||
assertHas(
|
||||
new RegExp(`AUDIT_GENERATION_STAGES\\s*=\\s*\\[[\\s\\S]*["']${stage}["'][\\s\\S]*\\]`),
|
||||
domainSource,
|
||||
`auditGenerationStage should include ${stage}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseSkillsRegistry, toAuditUsedSkill } from "../lib/skills-registry";
|
||||
test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source", () => {
|
||||
const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
|
||||
|
||||
assert.equal(parsed.length, 9);
|
||||
assert.equal(parsed.length, 10);
|
||||
const visualDesign = parsed.find((entry) => entry.id === "visual-design");
|
||||
assert.ok(visualDesign);
|
||||
assert.equal(visualDesign.title, "Visueller Gesamteindruck & Zeitgemäßheit");
|
||||
@@ -23,6 +23,21 @@ test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source
|
||||
assert.fail("Expected visual-design instructions to be parsed.");
|
||||
}
|
||||
assert.match(instructions, /Beurteile den ersten visuellen Eindruck/);
|
||||
|
||||
const critique = parsed.find((entry) => entry.id === "impeccable-critique");
|
||||
assert.ok(critique);
|
||||
assert.equal(critique.title, "Impeccable Critique Review");
|
||||
assert.equal(critique.appliesWhen, "website_exists");
|
||||
assert.deepEqual(critique.inputs, [
|
||||
"desktop_screenshot",
|
||||
"mobile_screenshot",
|
||||
"markdown",
|
||||
"dom",
|
||||
]);
|
||||
assert.match(
|
||||
critique.instructions ?? "",
|
||||
/Nielsen|kognitive Last|AI-Slop/,
|
||||
);
|
||||
});
|
||||
|
||||
test("toAuditUsedSkill exposes stable ids for v3 registry entries", () => {
|
||||
|
||||
@@ -228,6 +228,16 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup",
|
||||
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/,
|
||||
"getDetail should return audit, lead, and sourceSummaries.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/query\("auditFindings"\)[\s\S]*withIndex\("by_auditId"[\s\S]*eq\("auditId",\s*audit\._id\)[\s\S]*take\(DETAIL_EVIDENCE_LIMIT\)/,
|
||||
"getDetail should load persisted findings by auditId.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*findings,[\s\S]*sourceSummaries:[\s\S]*}/,
|
||||
"getDetail should return top-level findings for the detail UI.",
|
||||
);
|
||||
hasPattern(
|
||||
sourceFile.getFullText(),
|
||||
/export const getDetail = query\(/,
|
||||
|
||||
@@ -104,6 +104,11 @@ test("audit detail component uses getDetail query and renders skills overview se
|
||||
/const\s+lead\s*=\s*result\?\.lead;/,
|
||||
"AuditDetail should destructure lead from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+findings\s*=/,
|
||||
"AuditDetail should derive findings from result.findings.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/leadSummary\(\s*lead\s*\)/,
|
||||
@@ -141,6 +146,43 @@ test("audit detail component uses getDetail query and renders skills overview se
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component renders verified findings before checked-page evidence", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
const findingsIndex = detailSource.indexOf("Geprüfte Befunde");
|
||||
const checkedPagesIndex = detailSource.indexOf("Geprüfte Seiten");
|
||||
|
||||
assert.notEqual(findingsIndex, -1, "AuditDetail should render a findings section.");
|
||||
assert.notEqual(checkedPagesIndex, -1, "AuditDetail should still render checked pages.");
|
||||
assert.equal(
|
||||
findingsIndex < checkedPagesIndex,
|
||||
true,
|
||||
"Findings should be rendered before raw checked-page evidence.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/findings\.map/,
|
||||
"AuditDetail should render one row per verified finding.",
|
||||
);
|
||||
for (const field of [
|
||||
"claim",
|
||||
"recommendation",
|
||||
"customerBenefit",
|
||||
"evidenceRefs",
|
||||
"confidence",
|
||||
]) {
|
||||
assert.match(
|
||||
detailSource,
|
||||
new RegExp(field),
|
||||
`AuditDetail should surface finding.${field}.`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Evidence|Beleg|Quelle/,
|
||||
"AuditDetail should label evidence chips for each finding.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component renders compact checked-page evidence", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
|
||||
|
||||
181
tests/audit-specialist-schemas.test.ts
Normal file
181
tests/audit-specialist-schemas.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { zodSchema } from "ai";
|
||||
|
||||
import {
|
||||
auditEvidenceVerificationSchema,
|
||||
auditSpecialistResultSchema,
|
||||
} from "../lib/ai/schemas";
|
||||
|
||||
const validFinding = {
|
||||
skillId: "local-seo-basics",
|
||||
claim: "Die Startseite nennt den Standort im sichtbaren Bereich nicht klar.",
|
||||
recommendation: "Ort und wichtigste Leistung in die erste Überschrift aufnehmen.",
|
||||
customerBenefit: "Besucher erkennen schneller, ob das Angebot für sie passt.",
|
||||
severity: 2,
|
||||
confidence: 0.82,
|
||||
evidenceRefs: [
|
||||
{
|
||||
id: "crawl_page:homepage:https-example-com",
|
||||
type: "crawl_page",
|
||||
label: "Startseite",
|
||||
sourceUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
applies: true,
|
||||
unknowns: [],
|
||||
};
|
||||
|
||||
type JsonSchemaObject = {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
items?: JsonSchemaObject;
|
||||
};
|
||||
|
||||
function assertStrictRequiredProperties(schema: JsonSchemaObject, path = "schema") {
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
assert.equal(
|
||||
required.has(key),
|
||||
true,
|
||||
`${path}.${key} must be required for Azure/OpenAI structured outputs`,
|
||||
);
|
||||
assertStrictRequiredProperties(schema.properties[key]!, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items) {
|
||||
assertStrictRequiredProperties(schema.items, `${path}[]`);
|
||||
}
|
||||
}
|
||||
|
||||
test("specialist structured-output schemas require every declared property", () => {
|
||||
assertStrictRequiredProperties(
|
||||
zodSchema(auditSpecialistResultSchema).jsonSchema as JsonSchemaObject,
|
||||
);
|
||||
assertStrictRequiredProperties(
|
||||
zodSchema(auditEvidenceVerificationSchema).jsonSchema as JsonSchemaObject,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema accepts evidence-backed specialist findings", () => {
|
||||
const parsed = auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [validFinding],
|
||||
notes: ["Lokale Relevanz wurde anhand der Startseite geprüft."],
|
||||
});
|
||||
|
||||
assert.equal(parsed.status, "success");
|
||||
assert.equal(parsed.findings[0]?.evidenceRefs[0]?.type, "crawl_page");
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects findings without evidence refs", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, evidenceRefs: [] }],
|
||||
notes: [],
|
||||
}),
|
||||
/evidenceRefs/,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects unsupported severity and confidence", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, severity: 4 }],
|
||||
notes: [],
|
||||
}),
|
||||
/severity/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, confidence: 1.4 }],
|
||||
notes: [],
|
||||
}),
|
||||
/confidence/,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects unknown-only findings", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [
|
||||
{
|
||||
...validFinding,
|
||||
claim: "Kontaktformular: Unbekannt",
|
||||
recommendation: "Unbekannt prüfen.",
|
||||
customerBenefit: "Unbekannt.",
|
||||
evidenceRefs: [
|
||||
{
|
||||
id: "technical_check:unknown",
|
||||
type: "technical_check",
|
||||
label: "Kontaktformular unbekannt",
|
||||
sourceUrl: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: [],
|
||||
}),
|
||||
/unknown/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema returns compact verified ids and rejected decisions", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: ["finding-1"],
|
||||
rejectedFindings: [
|
||||
{
|
||||
findingId: "finding-2",
|
||||
skillId: validFinding.skillId,
|
||||
claim: "Die Seite koennte moderner wirken.",
|
||||
rejectionReason: "Zu generisch und nicht ausreichend belegt.",
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
notes: ["Ein Finding wurde wegen generischer Sprache verworfen."],
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed.verifiedFindingIds, ["finding-1"]);
|
||||
assert.equal(parsed.rejectedFindings[0]?.rejectionReason.includes("generisch"), true);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema accepts rejected unknown-only claims", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: [],
|
||||
rejectedFindings: [
|
||||
{
|
||||
findingId: "finding-1",
|
||||
skillId: "contact-conversion",
|
||||
claim: "Kontaktformular: Unbekannt",
|
||||
rejectionReason: "Unknown-only Befunde duerfen nicht in die Kundencopy.",
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
notes: [],
|
||||
});
|
||||
|
||||
assert.equal(parsed.rejectedFindings.length, 1);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema keeps verifier output compact for many findings", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: Array.from({ length: 12 }, (_, index) => `finding-${index + 1}`),
|
||||
rejectedFindings: [],
|
||||
contradictions: [],
|
||||
notes: ["Full specialist findings stay in application state and are not echoed."],
|
||||
});
|
||||
|
||||
assert.equal(parsed.verifiedFindingIds.length, 12);
|
||||
});
|
||||
38
tests/audits-board-layout.test.ts
Normal file
38
tests/audits-board-layout.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const auditsBoardPath = join(
|
||||
process.cwd(),
|
||||
"components",
|
||||
"audits",
|
||||
"audits-board.tsx",
|
||||
);
|
||||
|
||||
test("AuditsBoard renders dashboard rows as responsive cards", async () => {
|
||||
const source = await readFile(auditsBoardPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /min-w-\[/i);
|
||||
assert.doesNotMatch(source, /overflow-x-auto/i);
|
||||
assert.doesNotMatch(source, /grid-cols-\[minmax/i);
|
||||
|
||||
assert.match(source, /Card/);
|
||||
assert.match(source, /CardHeader/);
|
||||
assert.match(source, /CardTitle/);
|
||||
assert.match(source, /CardContent/);
|
||||
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /aria-labelledby=\{rowTitleId\}/);
|
||||
assert.match(source, /id=\{rowTitleId\}/);
|
||||
});
|
||||
|
||||
test("AuditsBoard keeps audit detail links and non-clickable pipeline cards", async () => {
|
||||
const source = await readFile(auditsBoardPath, "utf8");
|
||||
|
||||
assert.match(source, /row\.kind === "audit"/);
|
||||
assert.match(source, /href=\{row\.detailHref\}/);
|
||||
assert.match(source, /Öffnen/);
|
||||
assert.match(source, /Pipeline läuft/);
|
||||
assert.match(source, /getGenerationStatusLabel\(row\)/);
|
||||
assert.match(source, /row\.errorSummary/);
|
||||
});
|
||||
@@ -23,7 +23,9 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
||||
assert.doesNotMatch(source, /md:hidden/i);
|
||||
assert.doesNotMatch(source, /md:block/i);
|
||||
|
||||
assert.match(source, /className="grid gap-3"/);
|
||||
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /<Card[^>]+aria-labelledby=\{campaignTitleId\}/);
|
||||
assert.match(source, /id=\{campaignTitleId\}/);
|
||||
assert.match(source, /openEditDialog\(campaign\)/);
|
||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||
assert.match(source, /runCampaign\(campaign\)/);
|
||||
|
||||
27
tests/customer-tone-guidelines.test.ts
Normal file
27
tests/customer-tone-guidelines.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCustomerTonePromptSection,
|
||||
customerToneGuidelines,
|
||||
} from "../lib/ai/customer-tone-guidelines";
|
||||
|
||||
test("customer tone guidelines capture the selected collegial direct email posture", () => {
|
||||
assert.equal(customerToneGuidelines.senderPosture, "kollegial_direkt");
|
||||
assert.equal(customerToneGuidelines.email.wordCount.min, 60);
|
||||
assert.equal(customerToneGuidelines.email.wordCount.max, 130);
|
||||
assert.equal(customerToneGuidelines.email.subject.maxCharacters, 55);
|
||||
assert.equal(
|
||||
customerToneGuidelines.email.bannedPhrases.includes("Optimierungspotenziale"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("customer tone prompt section gives concrete first-contact email rules", () => {
|
||||
const promptSection = buildCustomerTonePromptSection();
|
||||
|
||||
assert.match(promptSection, /kollegial direkt/);
|
||||
assert.match(promptSection, /maximal zwei verifizierte Befunde/);
|
||||
assert.match(promptSection, /kein Mini-Audit/);
|
||||
assert.match(promptSection, /Soll ich Ihnen die zwei Punkte kurz schicken/);
|
||||
});
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
|
||||
const validPayload = {
|
||||
auditSummary:
|
||||
"Ich habe euren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
|
||||
"Ich habe Ihren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
|
||||
auditBody:
|
||||
"Mir ist aufgefallen, dass die Kontaktseite nur am Ende der Startseite eingebettet ist. Ich empfehle, sie im Kopfbereich direkt zu platzieren.",
|
||||
emailSubject: "Kurzes Feedback zu eurem Webauftritt",
|
||||
emailSubject: "Kurzer Website-Hinweis",
|
||||
emailBody:
|
||||
"Hallo, ich habe eure Seite betrachtet und festgestellt, dass die Kontaktoptionen auf mobilen Geräten schwer zu finden sind. Ich empfehle, einen klar sichtbaren Button einzubauen.",
|
||||
"Guten Tag, auf Ihrer Kontaktseite ist die Telefonnummer gut sichtbar, der mobile Kontaktbutton liegt aber erst weiter unten. Das kostet Besuchern unterwegs einen extra Schritt, gerade wenn sie schnell anrufen oder einen Termin anfragen wollen. Ich wollte Ihnen das kurz spiegeln, weil es mit wenig Aufwand klarer wirken kann. Mehr braucht es dafür wahrscheinlich nicht. Soll ich Ihnen die zwei Punkte kurz schicken?",
|
||||
callScript: {
|
||||
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
|
||||
callScript: [
|
||||
@@ -34,6 +34,18 @@ test("validateCustomerFacingCopy passes clean German outreach and audit copy", (
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy passes a natural short formal first-contact email", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailSubject: "Kurz zur Kontaktseite",
|
||||
emailBody:
|
||||
"Guten Tag, Ihre Telefonnummer ist auf der Kontaktseite gut auffindbar. Auf dem Handy rutscht der direkte Kontaktweg aber recht weit nach unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Ich wollte Ihnen das kurz zurückmelden, weil es ein kleiner Hebel für mehr Anfragen sein kann. Es geht nicht um einen großen Umbau. Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
@@ -75,11 +87,13 @@ test("validateCustomerFacingCopy flags short English artifact-like snippets in c
|
||||
}
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy requires Ich-form in applicable customer-facing fields", () => {
|
||||
test("validateCustomerFacingCopy requires Ich-form in applicable public audit and follow-up fields", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
|
||||
emailBody:
|
||||
"Guten Tag, Ihre Kontaktseite ist schon klar aufgebaut. Auf dem Handy liegt der direkte Kontaktweg aber recht weit unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Das ist vermutlich schnell zu beheben und würde den Einstieg einfacher machen. Soll ich Ihnen die konkrete Stelle kurz schicken?",
|
||||
followUp: "Die Website sollte verbessert werden. Setzt bitte einen Kontaktbutton.",
|
||||
});
|
||||
|
||||
@@ -187,10 +201,28 @@ test("validateCustomerFacingCopy strips technical artifacts like model ids and r
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy enforces observation + suggestion style", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Website wirkt freundlich und klar.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "auditBody" &&
|
||||
issue.rule === "missing_observation_or_suggestion",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks formulaic observed-and-suggested email copy", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Deine Website ist großartig, tolle Arbeit.",
|
||||
"Guten Tag, ich habe beobachtet, dass Ihre Website klare Kontaktinformationen bietet. Ich schlage vor, diese Sichtbarkeit auf allen Seiten beizubehalten. Ich habe beobachtet, dass außerdem die Ladezeiten verbesserungswürdig sind. Ich schlage vor, technische Maßnahmen umzusetzen, damit die Nutzererfahrung nachhaltig verbessert wird. Soll ich Ihnen dazu mehr Informationen senden?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
@@ -198,13 +230,50 @@ test("validateCustomerFacingCopy enforces observation + suggestion style", () =>
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" &&
|
||||
issue.rule === "missing_observation_or_suggestion",
|
||||
issue.rule === "formulaic_email_tone",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy accepts live audit copy with noun suggestion and collaborative close", () => {
|
||||
test("validateCustomerFacingCopy blocks long mini-audit outreach emails", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Guten Tag, auf Ihrer Website sind Adresse und Telefonnummer gut sichtbar. Außerdem fehlt eine aussagekräftige Meta-Beschreibung. Zudem sind die Ladezeiten auf mobilen Geräten verbesserungswürdig. Ein weiterer Punkt ist die Nutzerführung mit Call-to-Action-Elementen. Schließlich könnten lokale Vertrauenssignale wie Bewertungen ergänzt werden. Ich empfehle, diese Maßnahmen umzusetzen, um die Conversion-Rate zu steigern, Absprungraten zu senken und Ihr Ranking positiv zu beeinflussen. Soll ich Ihnen eine ausführliche Analyse schicken?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" &&
|
||||
(issue.rule === "email_reads_like_mini_audit" ||
|
||||
issue.rule === "brochure_email_language"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks inflated outreach subject lines", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailSubject:
|
||||
"Optimierungspotenziale für Ihre Website: Mehr Sichtbarkeit und bessere Nutzererfahrung",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailSubject" &&
|
||||
issue.rule === "unnatural_email_subject",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks old live audit copy that reads like generated outreach", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
auditSummary:
|
||||
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte zwar durch ihre klare Spezialisierung und umfassenden Kontaktinformationen überzeugt, jedoch durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen beim Laden an Nutzerkomfort verliert. Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und das Seitenlayout stabil zu gestalten, um das Vertrauen potenzieller Mandanten zu stärken und die Nutzerbindung nachhaltig zu erhöhen.",
|
||||
@@ -228,8 +297,17 @@ test("validateCustomerFacingCopy accepts live audit copy with noun suggestion an
|
||||
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten an Nutzerkomfort verliert. Mein konkreter Vorschlag ist, die Ladegeschwindigkeit gezielt zu optimieren und die Stabilität des Seitenlayouts sicherzustellen.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
(issue.field === "emailSubject" &&
|
||||
issue.rule === "unnatural_email_subject") ||
|
||||
(issue.field === "emailBody" &&
|
||||
issue.rule === "formulaic_email_tone"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy is permissive for phone numbers and date values", () => {
|
||||
@@ -238,9 +316,9 @@ test("validateCustomerFacingCopy is permissive for phone numbers and date values
|
||||
"Ich habe gesehen, dass eure Kontaktseite am 12.02.2026 aktualisiert wurde. Ich empfehle, den Kontaktbereich als Nächstes im Header zu verbessern.",
|
||||
auditBody:
|
||||
"Mir ist aufgefallen, dass die Telefonnummer 0201 123456 in der Fußzeile steht. Ich empfehle, sie zusätzlich im Header zu platzieren.",
|
||||
emailSubject: "Kurzes Feedback zu eurem Terminplan",
|
||||
emailSubject: "Kurz zum Terminplan",
|
||||
emailBody:
|
||||
"Hallo, ich habe euren Webauftritt geprüft und habe gesehen, dass Termine auf der Seite mit dem Datum 12. Oktober erwähnt sind. Ich empfehle, diese Terminangabe im Header stärker hervorzuheben.",
|
||||
"Guten Tag, auf Ihrer Seite ist der Termin am 12. Oktober schon erwähnt. Auf dem Handy steht diese Info aber recht weit unten, sodass Besucher sie leicht übersehen können, wenn sie nur schnell nach Öffnungszeiten oder Kontakt suchen. Ich wollte Ihnen das kurz zurückmelden, weil die Stelle ohne großen Umbau klarer werden kann. Mehr als eine kleine Umplatzierung braucht es vermutlich nicht. Soll ich Ihnen den Ausschnitt kurz schicken?",
|
||||
callScript: {
|
||||
openingLine:
|
||||
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
|
||||
|
||||
@@ -10,7 +10,7 @@ const leadsReviewPath = join(
|
||||
"leads-review-table.tsx",
|
||||
);
|
||||
|
||||
test("LeadsReviewTable uses compact card summaries with expandable review details", async () => {
|
||||
test("LeadsReviewTable uses compact card summaries with modal review details", async () => {
|
||||
const source = await readFile(leadsReviewPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /<table\b/i);
|
||||
@@ -21,22 +21,22 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
|
||||
assert.doesNotMatch(source, /<th\b/i);
|
||||
assert.doesNotMatch(source, /min-w-\[/i);
|
||||
|
||||
assert.match(source, /Dialog/);
|
||||
assert.match(source, /DialogContent/);
|
||||
assert.match(source, /DialogHeader/);
|
||||
assert.match(source, /DialogTitle/);
|
||||
assert.match(source, /DialogDescription/);
|
||||
assert.match(source, /max-h-\[calc\(100dvh-2rem\)\]/);
|
||||
assert.match(source, /overflow-y-auto/);
|
||||
assert.match(source, /Mehr anzeigen/);
|
||||
assert.match(source, /Weniger anzeigen/);
|
||||
assert.match(source, /aria-expanded=\{[^}]+\}/);
|
||||
assert.match(source, /aria-controls=\{[^}]+\}/);
|
||||
assert.match(source, /id=\{[^}]+\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/hidden=\{!?isExpanded\}/,
|
||||
);
|
||||
assert.doesNotMatch(source, /Weniger anzeigen/);
|
||||
assert.doesNotMatch(source, /aria-expanded=\{[^}]+\}/);
|
||||
assert.doesNotMatch(source, /aria-controls=\{[^}]+\}/);
|
||||
assert.doesNotMatch(source, /hidden=\{!?isExpanded\}/);
|
||||
|
||||
const companyNameMatch = source.match(
|
||||
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
|
||||
/<p className="([^"]+)"[^>]*>\s*\{lead\.companyName\}\s*<\/p>/,
|
||||
);
|
||||
assert.ok(
|
||||
companyNameMatch !== null &&
|
||||
@@ -110,3 +110,15 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
|
||||
assert.match(source, /Sperren/);
|
||||
assert.match(source, /Speichern/);
|
||||
});
|
||||
|
||||
test("LeadsReviewTable exposes count filters and live status feedback", async () => {
|
||||
const source = await readFile(leadsReviewPath, "utf8");
|
||||
|
||||
assert.match(source, /leadStatusFilters/);
|
||||
assert.match(source, /setActiveFilter/);
|
||||
assert.match(source, /Alle Leads/);
|
||||
assert.match(source, /Hohe Priorit(?:aet|ät)/);
|
||||
assert.match(source, /Gesperrt/);
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,21 @@ test("OutreachReviewWorkspace uses the review workspace API and required control
|
||||
].forEach((label) => assert.match(source, new RegExp(label)));
|
||||
});
|
||||
|
||||
test("OutreachReviewWorkspace renders a compact queue with one selected detail editor", async () => {
|
||||
const source = await readFile(outreachWorkspacePath, "utf8");
|
||||
|
||||
assert.match(source, /selectedRecordId/);
|
||||
assert.match(source, /selectedRecord/);
|
||||
assert.match(source, /Details prüfen/);
|
||||
assert.match(source, /Review-Queue/);
|
||||
assert.match(source, /reviewStatusFilters/);
|
||||
assert.match(source, /setActiveFilter/);
|
||||
assert.match(source, /Bereit zum Versand/);
|
||||
assert.match(source, /Mail offen/);
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /aria-pressed=\{selectedRecord\?\.id === record\.id\}/);
|
||||
});
|
||||
|
||||
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
|
||||
const source = await readFile(outreachWorkspacePath, "utf8");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user