@startsimpli/ui 0.4.52 → 0.4.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/catalog/catalog.ts +27 -0
- package/src/components/index.ts +10 -0
- package/src/components/review/ReviewNotes.tsx +130 -0
- package/src/components/review/ReviewScorecard.tsx +226 -0
- package/src/components/review/__tests__/review.test.tsx +114 -0
- package/src/components/review/index.ts +14 -0
- package/src/components/validation/ValidationChecklist.tsx +270 -0
- package/src/components/validation/__tests__/ValidationChecklist.test.tsx +84 -0
- package/src/components/validation/__tests__/checks.test.ts +191 -0
- package/src/components/validation/checks.ts +240 -0
- package/src/components/validation/index.ts +36 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
XCircle,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
ShieldAlert,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
import { cn } from '../../lib/utils';
|
|
12
|
+
import { overallStatus, type CheckStatus, type ContentCheck } from './checks';
|
|
13
|
+
|
|
14
|
+
/** Redundant (color + icon + label) tone per check status — never color alone. */
|
|
15
|
+
const CHECK_TONE: Record<CheckStatus, { label: string; dot: string; text: string; softBg: string; Icon: typeof CheckCircle2 }> = {
|
|
16
|
+
pass: { label: 'Passing', dot: 'bg-emerald-500', text: 'text-emerald-700', softBg: 'bg-emerald-50', Icon: CheckCircle2 },
|
|
17
|
+
warn: { label: 'Warnings', dot: 'bg-amber-500', text: 'text-amber-700', softBg: 'bg-amber-50', Icon: AlertTriangle },
|
|
18
|
+
fail: { label: 'Failing', dot: 'bg-red-500', text: 'text-red-700', softBg: 'bg-red-50', Icon: XCircle },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** One issue the AI judge raised against a guardrail. */
|
|
22
|
+
export interface JudgeIssue {
|
|
23
|
+
guardrail?: string;
|
|
24
|
+
severity?: string;
|
|
25
|
+
problem?: string;
|
|
26
|
+
fix?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The AI judge's output stored alongside the content record. */
|
|
30
|
+
export interface JudgeVerdict {
|
|
31
|
+
/** Overall verdict word (e.g. "approve", "revise", "reject"). */
|
|
32
|
+
verdict?: string;
|
|
33
|
+
/** Per-dimension scores, keyed by dimension name. */
|
|
34
|
+
scores?: Record<string, unknown>;
|
|
35
|
+
/** Specific problems the judge flagged. */
|
|
36
|
+
issues?: JudgeIssue[];
|
|
37
|
+
/** One-line prose summary. */
|
|
38
|
+
summary?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reviewer's manual override of the automated checks. */
|
|
42
|
+
export interface ValidationOverride {
|
|
43
|
+
overridden: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ValidationChecklistProps {
|
|
48
|
+
/** The (recomputed) deterministic checks to render. */
|
|
49
|
+
checks: ContentCheck[];
|
|
50
|
+
/** The AI judge output — renders a verdict badge, scores, and issues. */
|
|
51
|
+
judgeVerdict?: JudgeVerdict;
|
|
52
|
+
/** Current override state. When set, the overall pill reflects it. */
|
|
53
|
+
override?: ValidationOverride;
|
|
54
|
+
/** Provide to make the override affordance interactive. Omit → read-only. */
|
|
55
|
+
onOverride?: (next: ValidationOverride) => void;
|
|
56
|
+
title?: string;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A tone pill naming the roll-up state. */
|
|
61
|
+
function StatusPill({ status, label }: { status: CheckStatus; label?: string }) {
|
|
62
|
+
const tone = CHECK_TONE[status];
|
|
63
|
+
const Icon = tone.Icon;
|
|
64
|
+
return (
|
|
65
|
+
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', tone.softBg, tone.text)}>
|
|
66
|
+
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
|
|
67
|
+
{label ?? tone.label}
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Colour a judge verdict word without needing to know the exact vocabulary. */
|
|
73
|
+
function verdictStatus(verdict?: string): CheckStatus {
|
|
74
|
+
const v = (verdict ?? '').toLowerCase();
|
|
75
|
+
if (/pass|approve|accept|ok|good/.test(v)) return 'pass';
|
|
76
|
+
if (/fail|reject|block|deny|bad/.test(v)) return 'fail';
|
|
77
|
+
return 'warn';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Presentational, bring-your-own-data validation panel (768w.16.10.1).
|
|
82
|
+
*
|
|
83
|
+
* Surfaces the buried machine review of an AI-generated content record as a
|
|
84
|
+
* scannable pass/warn/fail checklist plus the AI judge's verdict — so a Foundry
|
|
85
|
+
* tenant can accept/reject in one glance. Stateless except for local UI toggles
|
|
86
|
+
* (expand issues, edit override reason); the app owns the data and persistence.
|
|
87
|
+
*/
|
|
88
|
+
export function ValidationChecklist({
|
|
89
|
+
checks,
|
|
90
|
+
judgeVerdict,
|
|
91
|
+
override,
|
|
92
|
+
onOverride,
|
|
93
|
+
title = 'Validation',
|
|
94
|
+
className,
|
|
95
|
+
}: ValidationChecklistProps) {
|
|
96
|
+
const overall = overallStatus(checks);
|
|
97
|
+
const [issuesOpen, setIssuesOpen] = React.useState(false);
|
|
98
|
+
const [overrideOpen, setOverrideOpen] = React.useState(false);
|
|
99
|
+
const [reason, setReason] = React.useState(override?.reason ?? '');
|
|
100
|
+
|
|
101
|
+
const counts = React.useMemo(
|
|
102
|
+
() => ({
|
|
103
|
+
pass: checks.filter((c) => c.status === 'pass').length,
|
|
104
|
+
warn: checks.filter((c) => c.status === 'warn').length,
|
|
105
|
+
fail: checks.filter((c) => c.status === 'fail').length,
|
|
106
|
+
}),
|
|
107
|
+
[checks],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const issues = judgeVerdict?.issues ?? [];
|
|
111
|
+
const scoreEntries = judgeVerdict?.scores ? Object.entries(judgeVerdict.scores) : [];
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('rounded-xl border bg-card text-card-foreground shadow p-5 space-y-4', className)}>
|
|
115
|
+
{/* Header: title + overall pill */}
|
|
116
|
+
<div className="flex items-center justify-between gap-3">
|
|
117
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
118
|
+
<ShieldCheck className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
|
|
119
|
+
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
122
|
+
{override?.overridden && (
|
|
123
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
|
124
|
+
<ShieldAlert className="w-3 h-3" aria-hidden="true" />
|
|
125
|
+
Overridden
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
<StatusPill status={override?.overridden ? 'pass' : overall} label={`${counts.pass}/${checks.length} passing`} />
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Checks list */}
|
|
133
|
+
{checks.length === 0 ? (
|
|
134
|
+
<p className="text-sm text-gray-500 py-2 text-center">No checks to show.</p>
|
|
135
|
+
) : (
|
|
136
|
+
<ul className="space-y-1.5">
|
|
137
|
+
{checks.map((check) => {
|
|
138
|
+
const tone = CHECK_TONE[check.status];
|
|
139
|
+
const Icon = tone.Icon;
|
|
140
|
+
return (
|
|
141
|
+
<li key={check.id} className="flex items-center gap-2.5 text-sm">
|
|
142
|
+
<span className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', tone.dot)} aria-hidden="true" />
|
|
143
|
+
<Icon className={cn('w-4 h-4 flex-shrink-0', tone.text)} aria-hidden="true" />
|
|
144
|
+
<span className="text-gray-900">{check.label}</span>
|
|
145
|
+
{check.detail && <span className="text-gray-500 truncate">— {check.detail}</span>}
|
|
146
|
+
<span className="sr-only">{tone.label}</span>
|
|
147
|
+
</li>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</ul>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* AI judge section */}
|
|
154
|
+
{judgeVerdict && (
|
|
155
|
+
<div className="rounded-lg border bg-gray-50/60 p-3 space-y-3">
|
|
156
|
+
<div className="flex items-center justify-between gap-3">
|
|
157
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">AI judge</span>
|
|
158
|
+
{judgeVerdict.verdict && <StatusPill status={verdictStatus(judgeVerdict.verdict)} label={judgeVerdict.verdict} />}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{judgeVerdict.summary && <p className="text-sm text-gray-700">{judgeVerdict.summary}</p>}
|
|
162
|
+
|
|
163
|
+
{scoreEntries.length > 0 && (
|
|
164
|
+
<dl className="flex flex-wrap gap-2">
|
|
165
|
+
{scoreEntries.map(([dim, val]) => (
|
|
166
|
+
<div key={dim} className="rounded-md bg-white border px-2 py-1 text-xs">
|
|
167
|
+
<dt className="text-gray-500">{dim}</dt>
|
|
168
|
+
<dd className="font-medium text-gray-900">{String(val)}</dd>
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
</dl>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{issues.length > 0 && (
|
|
175
|
+
<div>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => setIssuesOpen((o) => !o)}
|
|
179
|
+
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900"
|
|
180
|
+
aria-expanded={issuesOpen}
|
|
181
|
+
>
|
|
182
|
+
{issuesOpen ? <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronRight className="w-3.5 h-3.5" aria-hidden="true" />}
|
|
183
|
+
{issues.length} issue{issues.length === 1 ? '' : 's'}
|
|
184
|
+
</button>
|
|
185
|
+
{issuesOpen && (
|
|
186
|
+
<ul className="mt-2 space-y-2">
|
|
187
|
+
{issues.map((issue, i) => (
|
|
188
|
+
<li key={i} className="rounded-md border bg-white p-2 text-xs space-y-1">
|
|
189
|
+
<div className="flex items-center gap-2">
|
|
190
|
+
{issue.guardrail && <span className="font-medium text-gray-900">{issue.guardrail}</span>}
|
|
191
|
+
{issue.severity && (
|
|
192
|
+
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">{issue.severity}</span>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
{issue.problem && <p className="text-gray-700">{issue.problem}</p>}
|
|
196
|
+
{issue.fix && <p className="text-gray-500">Fix: {issue.fix}</p>}
|
|
197
|
+
</li>
|
|
198
|
+
))}
|
|
199
|
+
</ul>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Override affordance — interactive only when onOverride is provided */}
|
|
207
|
+
{onOverride && (
|
|
208
|
+
<div className="border-t pt-3">
|
|
209
|
+
{override?.overridden ? (
|
|
210
|
+
<div className="flex items-start justify-between gap-3 text-sm">
|
|
211
|
+
<div className="min-w-0">
|
|
212
|
+
<p className="font-medium text-gray-900">Checks overridden</p>
|
|
213
|
+
{override.reason && <p className="text-gray-500 break-words">{override.reason}</p>}
|
|
214
|
+
</div>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => onOverride({ overridden: false })}
|
|
218
|
+
className="text-xs font-medium text-gray-500 hover:text-gray-900 flex-shrink-0"
|
|
219
|
+
>
|
|
220
|
+
Clear
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
) : overrideOpen ? (
|
|
224
|
+
<div className="space-y-2">
|
|
225
|
+
<label className="text-xs font-medium text-gray-600" htmlFor="validation-override-reason">
|
|
226
|
+
Reason for overriding checks
|
|
227
|
+
</label>
|
|
228
|
+
<textarea
|
|
229
|
+
id="validation-override-reason"
|
|
230
|
+
value={reason}
|
|
231
|
+
onChange={(e) => setReason(e.target.value)}
|
|
232
|
+
rows={2}
|
|
233
|
+
className="w-full rounded-md border px-2 py-1 text-sm"
|
|
234
|
+
placeholder="Why is this content acceptable despite the failing checks?"
|
|
235
|
+
/>
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
disabled={reason.trim().length === 0}
|
|
240
|
+
onClick={() => {
|
|
241
|
+
onOverride({ overridden: true, reason: reason.trim() });
|
|
242
|
+
setOverrideOpen(false);
|
|
243
|
+
}}
|
|
244
|
+
className="rounded-md bg-gray-900 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
245
|
+
>
|
|
246
|
+
Override
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => setOverrideOpen(false)}
|
|
251
|
+
className="text-xs font-medium text-gray-500 hover:text-gray-900"
|
|
252
|
+
>
|
|
253
|
+
Cancel
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
) : (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => setOverrideOpen(true)}
|
|
261
|
+
className="text-xs font-medium text-gray-600 hover:text-gray-900"
|
|
262
|
+
>
|
|
263
|
+
Override checks (with reason)
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { ValidationChecklist } from '../ValidationChecklist';
|
|
4
|
+
import type { ContentCheck } from '../checks';
|
|
5
|
+
|
|
6
|
+
const checks: ContentCheck[] = [
|
|
7
|
+
{ id: 'blog-words', label: 'Blog word count', status: 'pass', detail: '450 words' },
|
|
8
|
+
{ id: 'linkedin-words', label: 'LinkedIn word count', status: 'warn', detail: '100 words' },
|
|
9
|
+
{ id: 'no-hype', label: 'No hype words', status: 'fail', detail: 'revolutionary' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('ValidationChecklist', () => {
|
|
13
|
+
it('renders every check with its label and detail', () => {
|
|
14
|
+
render(<ValidationChecklist checks={checks} />);
|
|
15
|
+
expect(screen.getByText('Blog word count')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('— 450 words')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('No hype words')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('summarises passing count in the overall pill', () => {
|
|
21
|
+
render(<ValidationChecklist checks={checks} />);
|
|
22
|
+
expect(screen.getByText('1/3 passing')).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders the judge verdict, scores, and expandable issues', () => {
|
|
26
|
+
render(
|
|
27
|
+
<ValidationChecklist
|
|
28
|
+
checks={checks}
|
|
29
|
+
judgeVerdict={{
|
|
30
|
+
verdict: 'revise',
|
|
31
|
+
summary: 'Reads well but sourcing is thin.',
|
|
32
|
+
scores: { clarity: 8, accuracy: 6 },
|
|
33
|
+
issues: [{ guardrail: 'sourcing', severity: 'high', problem: 'Unsupported claim', fix: 'Add a citation' }],
|
|
34
|
+
}}
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByText('AI judge')).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('revise')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Reads well but sourcing is thin.')).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText('clarity')).toBeInTheDocument();
|
|
41
|
+
|
|
42
|
+
// issues collapsed by default, expand on click
|
|
43
|
+
expect(screen.queryByText('Unsupported claim')).not.toBeInTheDocument();
|
|
44
|
+
fireEvent.click(screen.getByText('1 issue'));
|
|
45
|
+
expect(screen.getByText('Unsupported claim')).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText('Fix: Add a citation')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is read-only when no onOverride is provided', () => {
|
|
50
|
+
render(<ValidationChecklist checks={checks} />);
|
|
51
|
+
expect(screen.queryByText('Override checks (with reason)')).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('drives the override flow when onOverride is provided', () => {
|
|
55
|
+
const onOverride = jest.fn();
|
|
56
|
+
render(<ValidationChecklist checks={checks} onOverride={onOverride} />);
|
|
57
|
+
|
|
58
|
+
fireEvent.click(screen.getByText('Override checks (with reason)'));
|
|
59
|
+
const textarea = screen.getByLabelText('Reason for overriding checks');
|
|
60
|
+
// empty reason keeps the confirm disabled
|
|
61
|
+
expect(screen.getByRole('button', { name: 'Override' })).toBeDisabled();
|
|
62
|
+
|
|
63
|
+
fireEvent.change(textarea, { target: { value: 'Client approved the exception.' } });
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: 'Override' }));
|
|
65
|
+
expect(onOverride).toHaveBeenCalledWith({ overridden: true, reason: 'Client approved the exception.' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows an overridden banner and can clear the override', () => {
|
|
69
|
+
const onOverride = jest.fn();
|
|
70
|
+
render(
|
|
71
|
+
<ValidationChecklist
|
|
72
|
+
checks={checks}
|
|
73
|
+
override={{ overridden: true, reason: 'Approved by editor.' }}
|
|
74
|
+
onOverride={onOverride}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByText('Checks overridden')).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText('Approved by editor.')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText('Overridden')).toBeInTheDocument();
|
|
80
|
+
|
|
81
|
+
fireEvent.click(screen.getByText('Clear'));
|
|
82
|
+
expect(onOverride).toHaveBeenCalledWith({ overridden: false });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runContentChecks,
|
|
3
|
+
overallStatus,
|
|
4
|
+
wordCount,
|
|
5
|
+
tagCount,
|
|
6
|
+
extractUrls,
|
|
7
|
+
hostOf,
|
|
8
|
+
DEFAULT_HYPE_WORDS,
|
|
9
|
+
type ContentCheck,
|
|
10
|
+
} from '../checks';
|
|
11
|
+
|
|
12
|
+
/** Build a string of exactly `n` whitespace-delimited words. */
|
|
13
|
+
const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
|
14
|
+
const find = (checks: ContentCheck[], id: string) => checks.find((c) => c.id === id);
|
|
15
|
+
|
|
16
|
+
describe('word/tag helpers', () => {
|
|
17
|
+
it('wordCount handles empty, whitespace, and normal input', () => {
|
|
18
|
+
expect(wordCount(undefined)).toBe(0);
|
|
19
|
+
expect(wordCount('')).toBe(0);
|
|
20
|
+
expect(wordCount(' ')).toBe(0);
|
|
21
|
+
expect(wordCount('one two three')).toBe(3);
|
|
22
|
+
expect(wordCount(words(462))).toBe(462);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tagCount splits on commas and ignores empties', () => {
|
|
26
|
+
expect(tagCount(undefined)).toBe(0);
|
|
27
|
+
expect(tagCount('')).toBe(0);
|
|
28
|
+
expect(tagCount('a, b, c')).toBe(3);
|
|
29
|
+
expect(tagCount('a,,b, ,c,')).toBe(3);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('runContentChecks — field presence', () => {
|
|
34
|
+
it('emits no checks for an empty fields object (no approvedHosts)', () => {
|
|
35
|
+
expect(runContentChecks({})).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('only emits a check for a field that is present', () => {
|
|
39
|
+
const checks = runContentChecks({ blog: words(450) });
|
|
40
|
+
expect(checks.map((c) => c.id).sort()).toEqual(['blog-words', 'no-hype']);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('word-count bands', () => {
|
|
45
|
+
it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => {
|
|
46
|
+
expect(find(runContentChecks({ blog: words(450) }), 'blog-words')).toMatchObject({ status: 'pass', detail: '450 words' });
|
|
47
|
+
expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words' });
|
|
48
|
+
expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn');
|
|
49
|
+
// inclusive edges
|
|
50
|
+
expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass');
|
|
51
|
+
expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.status).toBe('pass');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('linkedin: default band 150–250', () => {
|
|
55
|
+
expect(find(runContentChecks({ linkedin: words(200) }), 'linkedin-words')!.status).toBe('pass');
|
|
56
|
+
expect(find(runContentChecks({ linkedin: words(100) }), 'linkedin-words')!.status).toBe('warn');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('headline: default band 8–12 words', () => {
|
|
60
|
+
expect(find(runContentChecks({ headline: words(10) }), 'headline-words')!.status).toBe('pass');
|
|
61
|
+
expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.status).toBe('warn');
|
|
62
|
+
expect(find(runContentChecks({ headline: words(20) }), 'headline-words')!.status).toBe('warn');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('honours a custom band override', () => {
|
|
66
|
+
const c = find(runContentChecks({ blog: words(100) }, { blogWords: [50, 150] }), 'blog-words');
|
|
67
|
+
expect(c!.status).toBe('pass');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('tag count and meta length', () => {
|
|
72
|
+
it('tags: default band 4–6', () => {
|
|
73
|
+
expect(find(runContentChecks({ tags: 'a,b,c,d,e' }), 'tags-count')).toMatchObject({ status: 'pass', detail: '5 tags' });
|
|
74
|
+
expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.status).toBe('warn');
|
|
75
|
+
expect(find(runContentChecks({ tags: 'a,b,c,d,e,f,g' }), 'tags-count')!.status).toBe('warn');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('meta description: default band 150–160 chars', () => {
|
|
79
|
+
const inBand = 'x'.repeat(155);
|
|
80
|
+
const tooShort = 'x'.repeat(120);
|
|
81
|
+
expect(find(runContentChecks({ metaDescription: inBand }), 'meta-length')).toMatchObject({ status: 'pass', detail: '155 chars' });
|
|
82
|
+
expect(find(runContentChecks({ metaDescription: tooShort }), 'meta-length')!.status).toBe('warn');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('[link] placeholder', () => {
|
|
87
|
+
it('passes when present, warns when absent', () => {
|
|
88
|
+
expect(find(runContentChecks({ linkedin: 'read more [link] now' }), 'link-placeholder')!.status).toBe('pass');
|
|
89
|
+
expect(find(runContentChecks({ linkedin: 'no placeholder here' }), 'link-placeholder')).toMatchObject({
|
|
90
|
+
status: 'warn',
|
|
91
|
+
detail: 'no [link] found',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is skipped when requireLink is false', () => {
|
|
96
|
+
expect(find(runContentChecks({ linkedin: 'no placeholder' }, { requireLink: false }), 'link-placeholder')).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('no-hype', () => {
|
|
101
|
+
it('fails when a hype word appears (case-insensitive) across headline/blog/linkedin', () => {
|
|
102
|
+
const c = find(runContentChecks({ headline: 'A REVOLUTIONARY launch', blog: words(450) }), 'no-hype');
|
|
103
|
+
expect(c!.status).toBe('fail');
|
|
104
|
+
expect(c!.detail).toContain('revolutionary');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('detects multi-word and hyphenated hype phrases', () => {
|
|
108
|
+
expect(find(runContentChecks({ blog: 'the best-in-class widget' }), 'no-hype')!.status).toBe('fail');
|
|
109
|
+
expect(find(runContentChecks({ blog: 'the most attractive option' }), 'no-hype')!.status).toBe('fail');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes clean prose', () => {
|
|
113
|
+
expect(find(runContentChecks({ blog: 'a measured, factual paragraph about widgets' }), 'no-hype')!.status).toBe('pass');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('honours a custom hype list', () => {
|
|
117
|
+
expect(find(runContentChecks({ blog: 'totally banana content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('fail');
|
|
118
|
+
// default hype words no longer apply when overridden
|
|
119
|
+
expect(find(runContentChecks({ blog: 'revolutionary content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('pass');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('ships a non-empty default hype list', () => {
|
|
123
|
+
expect(DEFAULT_HYPE_WORDS.length).toBeGreaterThan(5);
|
|
124
|
+
expect(DEFAULT_HYPE_WORDS).toContain('game-changing');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('approved sources', () => {
|
|
129
|
+
const approvedHosts = ['example.com', 'gov.uk'];
|
|
130
|
+
|
|
131
|
+
it('is skipped entirely when approvedHosts is omitted', () => {
|
|
132
|
+
expect(find(runContentChecks({ sources: 'https://example.com/a' }), 'approved-sources')).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes when every host is approved (www. stripped)', () => {
|
|
136
|
+
const c = find(runContentChecks({ sources: 'https://www.example.com/a https://gov.uk/b' }, { approvedHosts }), 'approved-sources');
|
|
137
|
+
expect(c!.status).toBe('pass');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('fails when any host is not on the allow-list', () => {
|
|
141
|
+
const c = find(runContentChecks({ sources: 'https://example.com/a https://sketchy.io/x' }, { approvedHosts }), 'approved-sources');
|
|
142
|
+
expect(c!.status).toBe('fail');
|
|
143
|
+
expect(c!.detail).toContain('sketchy.io');
|
|
144
|
+
expect(c!.detail).not.toContain('example.com');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('fails forum/social hosts even if listed', () => {
|
|
148
|
+
const c = find(
|
|
149
|
+
runContentChecks({ sources: 'https://reddit.com/r/x' }, { approvedHosts: ['reddit.com'] }),
|
|
150
|
+
'approved-sources',
|
|
151
|
+
);
|
|
152
|
+
expect(c!.status).toBe('fail');
|
|
153
|
+
expect(c!.detail).toContain('reddit.com');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('warns when there is no URL to check', () => {
|
|
157
|
+
expect(find(runContentChecks({ sources: 'no links here' }, { approvedHosts }), 'approved-sources')).toMatchObject({
|
|
158
|
+
status: 'warn',
|
|
159
|
+
detail: 'no sources to check',
|
|
160
|
+
});
|
|
161
|
+
// sources field absent but allow-list present → still a warn (nothing to check)
|
|
162
|
+
expect(find(runContentChecks({}, { approvedHosts }), 'approved-sources')!.status).toBe('warn');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('extractUrls + hostOf normalise correctly', () => {
|
|
166
|
+
expect(extractUrls('see https://a.com/x and http://b.org')).toEqual(['https://a.com/x', 'http://b.org']);
|
|
167
|
+
expect(extractUrls('none')).toEqual([]);
|
|
168
|
+
expect(hostOf('https://www.Example.com/path')).toBe('example.com');
|
|
169
|
+
expect(hostOf('not a url')).toBe('');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('overallStatus', () => {
|
|
174
|
+
const mk = (status: ContentCheck['status']): ContentCheck => ({ id: status, label: status, status });
|
|
175
|
+
|
|
176
|
+
it('fail beats warn beats pass; empty is pass', () => {
|
|
177
|
+
expect(overallStatus([])).toBe('pass');
|
|
178
|
+
expect(overallStatus([mk('pass'), mk('pass')])).toBe('pass');
|
|
179
|
+
expect(overallStatus([mk('pass'), mk('warn')])).toBe('warn');
|
|
180
|
+
expect(overallStatus([mk('warn'), mk('fail')])).toBe('fail');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rolls up a realistic mixed run', () => {
|
|
184
|
+
const checks = runContentChecks(
|
|
185
|
+
{ blog: words(450), linkedin: 'a revolutionary post [link]', tags: 'a,b,c,d,e' },
|
|
186
|
+
{},
|
|
187
|
+
);
|
|
188
|
+
// blog pass, linkedin word-count warn (2 words), link pass, hype fail, tags pass
|
|
189
|
+
expect(overallStatus(checks)).toBe('fail');
|
|
190
|
+
});
|
|
191
|
+
});
|