@operstack/audit 0.15.5 → 0.16.0
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 +2 -2
- package/src/collect.mjs +54 -6
- package/src/index.mjs +1 -1
- package/src/render.mjs +34 -2
- package/src/test-overall.mjs +90 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operstack/audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
31
|
+
"test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-rendered.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html",
|
|
32
32
|
"sample": "node bin/audit.mjs render examples/sample-audit.json --pdf"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
package/src/collect.mjs
CHANGED
|
@@ -449,6 +449,10 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
449
449
|
checks.push(row('conv-messenger', 'conversion', 'A messenger link', msg.length ? 'ok' : 'warn', msg.length ? `on ${msg.length} of ${pagesForTrust.length} sampled page(s)` : 'none on the sampled pages', msg.length ? '' : 'for an international audience reading on a phone, one messenger link is usually worth more than a form'));
|
|
450
450
|
|
|
451
451
|
const { scores, scoreBasis } = computeScores(checks);
|
|
452
|
+
|
|
453
|
+
// Один балл на весь отчёт: его же берут страница проверки и письмо, чтобы цифры не спорили.
|
|
454
|
+
|
|
455
|
+
const overall = computeOverall(checks);
|
|
452
456
|
// Оценки считаются до перевода: язык на цифры не влияет.
|
|
453
457
|
// Сколько текста не видно без выполнения скриптов. Меряется настоящим браузером и только по
|
|
454
458
|
// просьбе: запуск браузера это десятки секунд на страницу, и еженедельному мониторингу это ни к
|
|
@@ -475,6 +479,7 @@ export async function collect(startUrl, { pages = 20, log = () => {}, backlinks
|
|
|
475
479
|
return {
|
|
476
480
|
meta: { site: home.final, host, reachable: homeAnswered, collectedAt: new Date().toISOString(), tool: `@operstack/audit ${VERSION}`, auditType: lang === 'ru' ? 'Аудит по публичным сигналам' : 'External audit (no Search Console or analytics access)', lang, language: hp ? (hp.og?.locale || '') : '' },
|
|
477
481
|
client: { name: '{{CLIENT NAME}}', subject: '{{What the site sells and where}}', reportDate: new Date().toISOString().slice(0, 10), preparedBy: preparedBy || process.env.OPERSTACK_PREPARED_BY || 'OperStack' },
|
|
482
|
+
overall,
|
|
478
483
|
scores,
|
|
479
484
|
scoreBasis,
|
|
480
485
|
summary: { lead: '{{Three sentences: what the site is, what works, what holds it back.}}', verdict: '{{Key takeaway in three sentences, ending with how fast the critical issues can be fixed.}}', priorities: ['{{Priority one}}', '{{Priority two}}', '{{Priority three}}'] },
|
|
@@ -540,14 +545,45 @@ function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? s
|
|
|
540
545
|
* "not measured", never as a low score. Nothing in a report may be scored by hand.
|
|
541
546
|
*/
|
|
542
547
|
export const SCORE_AREAS = [
|
|
543
|
-
{ label: 'SEO, technical', groups: ['technical'] },
|
|
544
|
-
{ label: 'SEO, content and structure', groups: ['onpage', 'content'] },
|
|
545
|
-
{ label: 'AEO, answers and snippets', groups: ['aeo'] },
|
|
546
|
-
{ label: 'GEO, visibility in AI systems', groups: ['geo'] },
|
|
547
|
-
{ label: 'Off-page and trust', groups: ['offpage'] },
|
|
548
|
-
{ label: 'Conversion and UX', groups: ['conversion'] },
|
|
548
|
+
{ label: 'SEO, technical', groups: ['technical'], weight: 20 },
|
|
549
|
+
{ label: 'SEO, content and structure', groups: ['onpage', 'content'], weight: 20 },
|
|
550
|
+
{ label: 'AEO, answers and snippets', groups: ['aeo'], weight: 15 },
|
|
551
|
+
{ label: 'GEO, visibility in AI systems', groups: ['geo'], weight: 20 },
|
|
552
|
+
{ label: 'Off-page and trust', groups: ['offpage'], weight: 15 },
|
|
553
|
+
{ label: 'Conversion and UX', groups: ['conversion'], weight: 10 },
|
|
549
554
|
];
|
|
550
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Один балл из ста на весь продукт.
|
|
558
|
+
*
|
|
559
|
+
* 14.09.2026 Максим получил письмо, где в теле стояло «45 из 100», а во вложенном PDF шесть
|
|
560
|
+
* областей давали 44 из 60, то есть 73 процента. Человек читает в одном документе «ужас» и «почти
|
|
561
|
+
* всё хорошо» и делает единственный возможный вывод: цифры выдуманы. Считали их два разных движка
|
|
562
|
+
* с разными рамками, и это обесценивало оба продукта сразу.
|
|
563
|
+
*
|
|
564
|
+
* Поэтому балл считается здесь и только здесь, а страница проверки, письмо и отчёт берут его
|
|
565
|
+
* отсюда. Веса ниже это решение, а не измерение, и поэтому они напечатаны в самом отчёте: покупатель
|
|
566
|
+
* должен уметь пересчитать цифру руками, а не верить ей на слово.
|
|
567
|
+
*
|
|
568
|
+
* Область, которую этот прогон не измерял, не штрафуется и не считается за ноль: её вес выкидывается
|
|
569
|
+
* из знаменателя, и отчёт говорит, из чего именно сложился балл. Ноль за неизмеренное это враньё,
|
|
570
|
+
* ровно как «0 знаков» вместо «описания нет».
|
|
571
|
+
*/
|
|
572
|
+
export function computeOverall(checks) {
|
|
573
|
+
const { scores, scoreBasis } = computeScores(checks);
|
|
574
|
+
const parts = SCORE_AREAS
|
|
575
|
+
.map((a) => ({ label: a.label, weight: a.weight, score: scores[a.label], basis: scoreBasis[a.label] }))
|
|
576
|
+
.filter((p) => p.score !== null && p.score !== undefined);
|
|
577
|
+
const weighed = parts.reduce((sum, p) => sum + p.weight, 0);
|
|
578
|
+
if (!weighed) return { score: null, grade: 'not measured', weighed: 0, parts: [], note: 'nothing was measured, so there is no score' };
|
|
579
|
+
const score = Math.round(parts.reduce((sum, p) => sum + (p.score / 10) * p.weight, 0) / weighed * 100);
|
|
580
|
+
const grade = score >= 80 ? 'strong' : score >= 60 ? 'workable' : score >= 40 ? 'weak' : 'critical';
|
|
581
|
+
const note = weighed === 100
|
|
582
|
+
? `weighted from all ${parts.length} areas`
|
|
583
|
+
: `weighted from ${parts.length} of ${SCORE_AREAS.length} areas: ${weighed} of 100 points were measurable on this site`;
|
|
584
|
+
return { score, grade, weighed, parts, note };
|
|
585
|
+
}
|
|
586
|
+
|
|
551
587
|
/**
|
|
552
588
|
* Score every area from the collected checks: ok 1, warn 0.5, bad 0, na ignored, rounded to 0-10.
|
|
553
589
|
* Returns the scores and the count each one came from, so the report can print its own evidence.
|
|
@@ -576,6 +612,18 @@ export function verifyScores(audit) {
|
|
|
576
612
|
const { scores } = computeScores(audit.checks || []);
|
|
577
613
|
const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/10`);
|
|
578
614
|
const problems = [];
|
|
615
|
+
// Общий балл проверяется первым: именно он стоит в заголовке отчёта, в письме и на странице,
|
|
616
|
+
// и именно он обесценивает всё остальное, если разойдётся со своими же проверками.
|
|
617
|
+
//
|
|
618
|
+
// Сверяем только когда поле есть. Отчёт, собранный версией до 0.16.0, его не содержит, и это не
|
|
619
|
+
// ложь, а старый формат: в вёрстке заголовок всё равно считается из проверок этого же файла,
|
|
620
|
+
// поэтому отсутствие поля не может напечатать неверное число.
|
|
621
|
+
if (audit.overall && audit.overall.score !== undefined) {
|
|
622
|
+
const overall = computeOverall(audit.checks || []);
|
|
623
|
+
const stored = audit.overall.score;
|
|
624
|
+
const show = (v) => (v === null || v === undefined ? 'not measured' : `${v}/100`);
|
|
625
|
+
if (stored !== overall.score) problems.push(`overall: the report says ${show(stored)}, the checks give ${show(overall.score)}`);
|
|
626
|
+
}
|
|
579
627
|
for (const [label, expected] of Object.entries(scores)) {
|
|
580
628
|
const stored = (audit.scores || {})[label];
|
|
581
629
|
const actual = stored === undefined ? null : stored;
|
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { collect, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
1
|
+
export { collect, computeScores, computeOverall, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
2
2
|
export { render, checkNarrative } from './render.mjs';
|
|
3
3
|
export { localiseChecks, localiseBasisNote, untranslated, AREAS_RU } from './i18n.mjs';
|
|
4
4
|
export { buildFixPlan, renderFixPlan, renderFixChecklist, buildFixReport, renderFixReport, FIX_ACTIONS } from './fix.mjs';
|
package/src/render.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { writeFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { computeScores } from './collect.mjs';
|
|
10
|
+
import { computeScores, computeOverall } from './collect.mjs';
|
|
11
11
|
import { localiseBasisNote, AREAS_RU } from './i18n.mjs';
|
|
12
12
|
import { softenHex } from './agency.mjs';
|
|
13
13
|
|
|
@@ -25,6 +25,13 @@ const LABELS = {
|
|
|
25
25
|
secSummary: 'Executive summary', summaryEyebrow: '01 · Summary', keyTakeaway: 'Key takeaway:', threePriorities: 'Three priorities',
|
|
26
26
|
scorecardFoot: 'Each score counts the checks in this report: a check that passes scores one, a check that needs attention a half, a failing check nothing. An area marked <strong>not measured</strong> is an area this audit does not test, and is never scored on an impression. Every figure here can be recomputed from section 04 onwards.',
|
|
27
27
|
notMeasured: 'not measured',
|
|
28
|
+
overallLabel: 'Overall score',
|
|
29
|
+
grades: { strong: 'Strong', workable: 'Workable', weak: 'Weak', critical: 'Critical' },
|
|
30
|
+
// Веса это решение, а не измерение, поэтому они напечатаны здесь: балл должен пересчитываться руками.
|
|
31
|
+
overallNote: (o) => `Weighted from the areas below: ${o.parts.map((x) => `${x.label} ${x.weight}`).join(', ')}. `
|
|
32
|
+
+ (o.weighed === 100
|
|
33
|
+
? 'Every area was measurable on this site.'
|
|
34
|
+
: `Only ${o.weighed} of 100 points were measurable here, so the areas this audit could not read are left out of the total rather than scored zero.`),
|
|
28
35
|
secOverview: 'Site overview', overviewEyebrow: '02 · Overview', whatSiteIs: 'What the site is', parameter: 'Parameter', value: 'Value', pagesSampled: 'Pages sampled', colUrl: 'URL', colTitle: 'Title', colWords: 'Words',
|
|
29
36
|
secCritical: 'Critical issues', criticalEyebrow: '03 · P0', criticalTitle: 'Critical issues, fix first', whatItCosts: 'What it costs', theFix: 'Fix', noBody: 'This issue has no description in the audit file.', noCritical: 'No critical defects were found in the public signals.',
|
|
30
37
|
secTechnical: 'Technical SEO', technicalEyebrow: '04 · Technical', technicalTitle: 'Technical and on-page checklist', colCheck: 'Check', colStatus: 'Status', colFinding: 'Finding',
|
|
@@ -48,6 +55,12 @@ const LABELS = {
|
|
|
48
55
|
secSummary: 'Главное', summaryEyebrow: '01 · Итог', keyTakeaway: 'Главный вывод:', threePriorities: 'Три приоритета',
|
|
49
56
|
scorecardFoot: 'Каждая оценка считает проверки из этого же отчёта: пройденная проверка это балл, спорная половина балла, проваленная ноль. Область с пометкой <strong>не измерялось</strong> это область, которую аудит не проверяет, и она никогда не оценивается на глаз. Любую цифру отсюда можно пересчитать по разделам начиная с четвёртого.',
|
|
50
57
|
notMeasured: 'не измерялось',
|
|
58
|
+
overallLabel: 'Общий балл',
|
|
59
|
+
grades: { strong: 'Сильно', workable: 'Рабочее состояние', weak: 'Слабо', critical: 'Критично' },
|
|
60
|
+
overallNote: (o) => `Взвешен по областям ниже: ${o.parts.map((x) => `${AREAS_RU[x.label] || x.label} ${x.weight}`).join(', ')}. `
|
|
61
|
+
+ (o.weighed === 100
|
|
62
|
+
? 'Все области на этом сайте удалось измерить.'
|
|
63
|
+
: `Измеримыми здесь оказались ${o.weighed} ${ruPoints(o.weighed)} из 100, поэтому области, которые аудит прочитать не смог, не попали в знаменатель, а не получили ноль.`),
|
|
51
64
|
secOverview: 'О сайте', overviewEyebrow: '02 · Обзор', whatSiteIs: 'Что это за сайт', parameter: 'Параметр', value: 'Значение', pagesSampled: 'Проверенные страницы', colUrl: 'Адрес', colTitle: 'Заголовок', colWords: 'Слов',
|
|
52
65
|
secCritical: 'Критичное', criticalEyebrow: '03 · Срочно', criticalTitle: 'Что чинить первым', whatItCosts: 'Чем это грозит', theFix: 'Как чинится', noBody: 'У этой проблемы нет описания в файле аудита.', noCritical: 'Критичных дефектов в публичных сигналах не найдено.',
|
|
53
66
|
secTechnical: 'Техническое SEO', technicalEyebrow: '04 · Техника', technicalTitle: 'Техническая проверка и страницы', colCheck: 'Проверка', colStatus: 'Статус', colFinding: 'Что нашли',
|
|
@@ -68,6 +81,9 @@ const LABELS = {
|
|
|
68
81
|
const STATUS_KEYS = { ok: 'statusOk', warn: 'statusWarn', bad: 'statusBad', na: 'statusNa' };
|
|
69
82
|
const STATUS_CLASS = { ok: 'status-ok', warn: 'status-warn', bad: 'status-bad', na: 'status-na' };
|
|
70
83
|
const STATUS_UNUSED = { ok: ['✓ OK', 'status-ok'], warn: ['△ Partial', 'status-warn'], bad: ['✗ Problem', 'status-bad'], na: ['· Note', 'status-na'] };
|
|
84
|
+
/** Склонение «балл» для русской подписи под общим баллом: 21 балл, 72 балла, 75 баллов. */
|
|
85
|
+
const ruPoints = (n) => { const a = Math.abs(n) % 100; const b = a % 10; if (a > 10 && a < 20) return 'баллов'; if (b === 1) return 'балл'; if (b >= 2 && b <= 4) return 'балла'; return 'баллов'; };
|
|
86
|
+
|
|
71
87
|
const scoreClass = (n) => (n === null || n === undefined ? '' : n <= 3 ? 'low' : n <= 6 ? 'mid' : 'ok');
|
|
72
88
|
|
|
73
89
|
export function checkNarrative(audit) {
|
|
@@ -110,6 +126,13 @@ function css() {
|
|
|
110
126
|
.page-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 3mm; margin-bottom: 6mm; border-bottom: 1px solid var(--rule); font-size: 8pt; color: var(--muted); }
|
|
111
127
|
.page-header strong { color: var(--accent); }
|
|
112
128
|
.page-footer { position: absolute; bottom: 9mm; left: var(--pad); right: var(--pad); display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: 1px solid var(--rule); padding-top: 2.5mm; }
|
|
129
|
+
.overall { display: flex; align-items: center; gap: 6mm; border: 1px solid var(--rule); border-radius: 5px; padding: 5mm 6mm; background: #f4f8f8; margin: 5mm 0 4mm; }
|
|
130
|
+
.overall-num { font-family: "Fraunces", Georgia, serif; font-size: 44pt; font-weight: 600; line-height: 1; color: var(--accent); font-variant-numeric: tabular-nums; }
|
|
131
|
+
.overall-num span { font-size: 18pt; color: var(--muted); }
|
|
132
|
+
.overall-num.low { color: var(--danger); } .overall-num.mid { color: var(--warn); } .overall-num.ok { color: var(--ok); }
|
|
133
|
+
.overall-label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
|
134
|
+
.overall-grade { font-family: "Fraunces", Georgia, serif; font-size: 15pt; font-weight: 600; margin: 1mm 0 1.5mm; }
|
|
135
|
+
.overall-note { font-size: 8.5pt; color: var(--muted); line-height: 1.45; }
|
|
113
136
|
.scorecard { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin: 5mm 0 6mm; }
|
|
114
137
|
.score { border: 1px solid var(--rule); border-radius: 4px; padding: 3.5mm 4mm; background: #fafbfb; }
|
|
115
138
|
.score .label { font-size: 8pt; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 1.5mm; }
|
|
@@ -157,6 +180,15 @@ export function toHtml(audit, brand = null) {
|
|
|
157
180
|
const groups = [['technical', 'Technical'], ['onpage', 'On-page'], ['content', 'Content'], ['aeo', 'Answer engines'], ['geo', 'Generative engines'], ['offpage', 'Off-page and trust'], ['conversion', 'Conversion'], ['overview', 'Overview']];
|
|
158
181
|
const checksBy = (g) => (a.checks || []).filter((c) => c.group === g);
|
|
159
182
|
const { scores: measured, scoreBasis } = computeScores(a.checks || []);
|
|
183
|
+
// Один балл на весь отчёт стоит заголовком, шесть областей под ним расшифровкой. До 14.09.2026
|
|
184
|
+
// в письме и в PDF стояли числа из двух разных движков, и читатель делал единственный возможный
|
|
185
|
+
// вывод: цифры выдуманы. Веса печатаются рядом, чтобы балл можно было пересчитать руками.
|
|
186
|
+
const overall = computeOverall(a.checks || []);
|
|
187
|
+
const overallBlock = overall.score === null ? '' : `<div class="overall">
|
|
188
|
+
<div class="overall-num ${scoreClass(Math.round(overall.score / 10))}">${overall.score}<span>/100</span></div>
|
|
189
|
+
<div class="overall-side"><div class="overall-label">${esc(L.overallLabel)}</div>
|
|
190
|
+
<div class="overall-grade">${esc(L.grades[overall.grade] || overall.grade)}</div>
|
|
191
|
+
<div class="overall-note">${esc(L.overallNote(overall))}</div></div></div>`;
|
|
160
192
|
const scoreCards = Object.entries(measured).map(([label, n]) => {
|
|
161
193
|
const basis = scoreBasis[label] || {};
|
|
162
194
|
const shown = (L.areas && L.areas[label]) || label;
|
|
@@ -177,7 +209,7 @@ export function toHtml(audit, brand = null) {
|
|
|
177
209
|
<dl class="cover-meta"><div><dt>${L.auditSubject}</dt><dd>${t(a.client.name)}</dd></div><div><dt>${L.reportDate}</dt><dd>${esc(a.client.reportDate)}</dd></div><div><dt>${L.auditType}</dt><dd>${esc(a.meta.auditType)}</dd></div><div><dt>${L.preparedBy}</dt><dd>${esc(preparedBy)}</dd></div></dl></div>
|
|
178
210
|
${toolLine}</div></div>`);
|
|
179
211
|
n++;
|
|
180
|
-
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p
|
|
212
|
+
pages.push(`<div class="page">${header(a, L.secSummary, L)}<div class="eyebrow">${L.summaryEyebrow}</div><h2>${L.secSummary}</h2><p class="lead">${t(a.summary.lead)}</p>${overallBlock}<div class="scorecard">${scoreCards}</div><p class="scorecard-foot">${L.scorecardFoot}</p><div class="verdict"><p><strong>${L.keyTakeaway}</strong> ${t(a.summary.verdict)}</p></div><h3>${L.threePriorities}</h3>${olist(a.summary.priorities)}${footer(a, n++, preparedBy)}</div>`);
|
|
181
213
|
pages.push(`<div class="page">${header(a, L.secOverview, L)}<div class="eyebrow">${L.overviewEyebrow}</div><h2>${L.whatSiteIs}</h2><table><tr><th>${L.parameter}</th><th>${L.value}</th></tr>${(a.overview.rows || []).map(([k, v]) => `<tr><td>${t(k)}</td><td>${t(v)}</td></tr>`).join('')}</table><p>${t(a.overview.note)}</p><h3>${L.pagesSampled}</h3><table><tr><th>${L.colUrl}</th><th>${L.colTitle}</th><th>${L.colWords}</th><th>H1</th><th>Alt</th></tr>${(a.sample || []).filter((p) => p.title !== undefined).slice(0, 14).map((p) => `<tr><td><code>${esc(new URL(p.url).pathname)}</code></td><td>${esc(p.title)}</td><td>${p.words}</td><td>${p.h1Count}</td><td>${p.images ? `${p.images - p.imagesNoAlt}/${p.images}` : '·'}</td></tr>`).join('')}</table>${footer(a, n++, preparedBy)}</div>`);
|
|
182
214
|
const cardBody = (c) => [
|
|
183
215
|
c.text ? `<p>${t(c.text)}</p>` : '',
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Один балл из ста: и в объекте аудита, и в отчёте, и на обоих языках.
|
|
3
|
+
*
|
|
4
|
+
* 14.09.2026 человек получил письмо с «45 из 100» и приложенный PDF, где шесть областей давали
|
|
5
|
+
* 44 из 60, то есть 73 процента. Два разных движка в одном документе читаются как выдуманные
|
|
6
|
+
* цифры. Эти тесты держат единственный источник балла и запрет считать неизмеренное нулём. */
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { computeOverall, computeScores, verifyScores, SCORE_AREAS } from './collect.mjs';
|
|
9
|
+
import { toHtml } from './render.mjs';
|
|
10
|
+
|
|
11
|
+
let bad = 0;
|
|
12
|
+
const ok = (n, c) => { if (c) console.log(`ok ${n}`); else { bad++; console.error(`FAIL ${n}`); } };
|
|
13
|
+
const is = (n, a, b) => ok(`${n} (ждали ${JSON.stringify(b)}, вышло ${JSON.stringify(a)})`, JSON.stringify(a) === JSON.stringify(b));
|
|
14
|
+
|
|
15
|
+
const c = (group, status, i = 0) => ({ id: `${group}-${status}-${i}`, group, status, label: group, value: 'x' });
|
|
16
|
+
|
|
17
|
+
// Веса это решение, а не измерение, но сумма обязана быть сотней, иначе балл не из ста.
|
|
18
|
+
is('веса дают ровно сто', SCORE_AREAS.reduce((s, a) => s + a.weight, 0), 100);
|
|
19
|
+
|
|
20
|
+
// Всё пройдено это сто, всё провалено это ноль.
|
|
21
|
+
const every = (status) => SCORE_AREAS.flatMap((a) => a.groups.map((g) => c(g, status)));
|
|
22
|
+
is('все проверки пройдены: сто', computeOverall(every('ok')).score, 100);
|
|
23
|
+
is('все проверки провалены: ноль', computeOverall(every('bad')).score, 0);
|
|
24
|
+
is('все спорные: половина', computeOverall(every('warn')).score, 50);
|
|
25
|
+
|
|
26
|
+
// Неизмеренная область выкидывается из знаменателя, а не получает ноль. Ноль за то, чего не
|
|
27
|
+
// мерили, это неверное измерение, ровно как «0 знаков» вместо «описания у страницы нет».
|
|
28
|
+
{
|
|
29
|
+
const partial = [c('technical', 'ok'), c('geo', 'ok')];
|
|
30
|
+
const o = computeOverall(partial);
|
|
31
|
+
is('измеренное на отлично даёт сто даже при неполном покрытии', o.score, 100);
|
|
32
|
+
is('в знаменатель попали только измеренные веса', o.weighed, 40);
|
|
33
|
+
ok('и отчёт об этом говорит', /40 of 100 points were measurable/.test(o.note));
|
|
34
|
+
ok('неизмеренные области в разбивку не попали', o.parts.length === 2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Пустой аудит не выдаёт ноль: нечего мерить это не то же самое, что всё плохо.
|
|
38
|
+
{
|
|
39
|
+
const o = computeOverall([]);
|
|
40
|
+
is('пустой набор проверок не даёт нуля', o.score, null);
|
|
41
|
+
is('и называется неизмеренным', o.grade, 'not measured');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Проверки со статусом na не считаются ни в плюс, ни в минус.
|
|
45
|
+
{
|
|
46
|
+
const withNa = [c('technical', 'ok'), { ...c('technical', 'na'), status: 'na' }];
|
|
47
|
+
is('na не портит и не улучшает балл', computeOverall(withNa).score, 100);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Балл в отчёте обязан сходиться с проверками этого же отчёта.
|
|
51
|
+
{
|
|
52
|
+
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
53
|
+
const o = computeOverall(audit.checks || []);
|
|
54
|
+
audit.overall = o;
|
|
55
|
+
const problems = verifyScores(audit);
|
|
56
|
+
ok('честный отчёт проходит проверку', !problems.some((p) => p.startsWith('overall')));
|
|
57
|
+
audit.overall = { ...o, score: (o.score + 7) % 101 };
|
|
58
|
+
ok('подделанный балл ловится', verifyScores(audit).some((p) => p.startsWith('overall')));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Оба языка. Сегодня дважды чинили русскую ветку и оставляли английскую сломанной.
|
|
62
|
+
{
|
|
63
|
+
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
64
|
+
const expected = computeOverall(audit.checks || []).score;
|
|
65
|
+
for (const [lang, grade, note] of [['en', /Strong|Workable|Weak|Critical/, /Weighted from the areas below/], ['ru', /Сильно|Рабочее состояние|Слабо|Критично/, /Взвешен по областям ниже/]]) {
|
|
66
|
+
audit.meta.lang = lang;
|
|
67
|
+
const html = toHtml(audit, null);
|
|
68
|
+
const num = (html.match(/overall-num[^>]*>(\d+)<span>/) || [])[1];
|
|
69
|
+
is(`${lang}: в отчёте стоит тот же балл, что считают проверки`, Number(num), expected);
|
|
70
|
+
ok(`${lang}: оценка словом на своём языке`, grade.test((html.match(/overall-grade">([^<]*)</) || [])[1] || ''));
|
|
71
|
+
ok(`${lang}: веса напечатаны, балл можно пересчитать руками`, note.test(html));
|
|
72
|
+
ok(`${lang}: в подписи нет чужого языка`, lang === 'en'
|
|
73
|
+
? !/[А-Яа-яЁё]/.test((html.match(/overall-note">([^<]*)</) || [])[1] || '')
|
|
74
|
+
: !/Weighted|measurable/.test((html.match(/overall-note">([^<]*)</) || [])[1] || ''));
|
|
75
|
+
}
|
|
76
|
+
// Русское склонение: 75 баллов, а не 75 балла.
|
|
77
|
+
audit.meta.lang = 'ru';
|
|
78
|
+
ok('русское склонение «баллов» верное', !/\d+ балла из 100/.test(toHtml(audit, null)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Шесть областей никуда не делись: общий балл их дополняет, а не заменяет.
|
|
82
|
+
{
|
|
83
|
+
const audit = JSON.parse(readFileSync(new URL('../examples/sample-audit.json', import.meta.url), 'utf8'));
|
|
84
|
+
const { scores } = computeScores(audit.checks || []);
|
|
85
|
+
const html = toHtml(audit, null);
|
|
86
|
+
ok('разбивка по областям осталась под общим баллом', Object.keys(scores).every((k) => html.includes('/10') || scores[k] === null));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (bad) { console.error(`\n${bad} тест(ов) упало`); process.exit(1); }
|
|
90
|
+
console.log('\nвсе тесты общего балла прошли');
|