@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.15.5",
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><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>`);
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все тесты общего балла прошли');