Как проверять HTML и CSS, если правильных решений несколько

Условия учебного задания определяют, какие различия между решениями допустимы. Если важен только вид рамки, допустимы разные способы её задать. Но если ученик должен написать закрывающий тег, одного правильного вида страницы недостаточно: браузер может исправить его ошибку. От этого различия зависит, что должна проверять программа — исходный код или результат его работы.

В основе материала — опыт, о котором я рассказывал в докладе «Автоматизация разработки курсов: путь от рутины к игре» в октябре 2014 года. Учебное задание и запускаемые примеры подготовлены в октябре 2026 года; они используют современные JavaScript, браузерные API и parse5.

При разработке учебных проверок в HTML Academy мы столкнулись с тем, что одного сходства с образцом недостаточно. Ученику нужно понимать, что у него получилось и где он ошибся. При этом несколько способов решения могут быть одинаково хороши. Если проверка принимает только вариант автора задания, ученику остаётся угадывать, какую запись от него ждут.

Для статьи я подготовил небольшое задание с карточкой. На нём можно увидеть, как выбор способа проверки меняет набор принимаемых решений, а затем проверить сам тест на правильных и ошибочных вариантах.

Рамка есть, проверка не согласна

Начальная проверка может выглядеть так:

const passed = css.includes('border: 1px solid red;');

Она отвергает вполне подходящее решение:

#card {
  border-width: 1px;
  border-style: solid;
  border-color: rgb(255 0 0);
}

И примет исходник, в котором нужная строка осталась только в комментарии. Можно учитывать пробелы, разбирать сокращённые свойства и добавлять варианты записи цвета. Но на следующем задании придётся повторить часть работы CSS-парсера, а потом ещё разобраться с каскадом.

Вторая проверка смотрит на результат разбора HTML:

const passed = Boolean(document.querySelector('h1'));

Для такого исходника она вернёт true:

<article id="card"><h1>Карточка</article>

Элемент действительно есть. Только исходный код от этого не стал содержать закрывающий </h1>.

В первом случае тесту мешает допустимое различие в записи CSS. Во втором он не замечает ошибку в исходном HTML. Прежде чем исправлять код, придётся решить, что именно мы хотим узнать о работе ученика.

Что именно должен сделать ученик

У карточки есть заголовок «Карточка», оформленный элементом h1 с явно написанным закрывающим тегом. Текст заголовка красный. Со всех сторон карточки нужна сплошная красная рамка толщиной в один CSS-пиксель. Карточку нужно повернуть на 90 градусов вокруг верхнего левого угла и сдвинуть на 12 CSS-пикселей вправо.

Структура задана: в документе один #stage, непосредственно в нём один #card, а непосредственно в карточке — единственный h1. Дополнительные обёртки в этом упражнении не нужны.

Поворот и перенос задаём через transform. Отдельные свойства translate и rotate в этом упражнении не используем, поэтому проверка будет читать матрицу именно из transform.

Размеры заданы заранее: родитель шириной 240 пикселей, карточка занимает половину этой ширины и имеет высоту 40 пикселей вместе с рамкой. Их менять не нужно. Это позволит проверить, что правильная матрица преобразования не скрыла изменение самой карточки.

В таком задании допустимы длинные свойства рамки и разные записи красного цвета. Допустимо и наследование цвета: нас интересует цвет текста. Если бы заданием было «добавьте color в правило #card», пришлось бы проверять ещё и исходный CSS. Это другое требование, даже если образец на экране выглядит одинаково.

Получается такое распределение проверок:

Условие Где проверяем
Закрывающий h1 написан Исходный HTML
В карточке один заголовок с нужным текстом DOM
Цвет текста и каждая сторона рамки Вычисленные стили
Преобразование через transform соответствует условию Числа из матрицы
Размер и положение карточки сохранены Геометрия в браузере

Каждая проверка отвечает за своё условие задания. Совпадение картинки не заменяет проверку исходника, а правильная запись свойства ещё не означает, что оно повлияло на результат.

Эталон в том же браузере

Пусть HTML образца выглядит так:

<div id="stage">
  <article id="card">
    <h1>Карточка</h1>
  </article>
</div>

А его CSS так:

body {
  margin: 0;
  font: 16px / 20px sans-serif;
}

#stage {
  width: 240px;
  height: 180px;
  margin: 50px;
}

#card {
  box-sizing: border-box;
  width: 50%;
  height: 40px;
  margin: 0;
  border: 1px solid red;
  color: red;
  transform-origin: 0 0;
  transform: translateX(12px) rotate(90deg);
}

h1 {
  margin: 0;
  font: inherit;
}

Эталон и решение помещаем в разные iframe одинакового размера. Так стили ученика не попадут в эталон, а стили окружающей страницы не изменят оба документа. Размеры внутри тоже должны совпадать: карточка занимает половину ширины родителя, поэтому при 240 и 480 пикселях результат будет разным.

Для подготовленных примеров не требуется интерфейс редактора. Достаточно создать документ и дождаться загрузки:

function frame(html, css) {
  return new Promise((resolve, reject) => {
    const element = document.createElement('iframe');
    element.setAttribute('sandbox', 'allow-same-origin');

    const timer = setTimeout(() => {
      element.remove();
      reject(new Error('Учебный документ не загрузился'));
    }, 3000);

    element.addEventListener(
      'load',
      () => {
        clearTimeout(timer);
        resolve(element);
      },
      { once: true }
    );

    element.srcdoc = `<!doctype html><meta charset="utf-8"><style>${css}</style>${html}`;
    document.body.append(element);
  });
}

Этим фреймам зададим ширину 320 и высоту 300 пикселей и вынесем их за видимую область через абсолютное позиционирование. display: none здесь не подходит: нам ещё предстоит измерять размеры элементов, а у скрытого таким способом документа нет нужной раскладки.

В примерах нет внешних ресурсов и анимации; после load можно измерять неподвижные элементы. JavaScript внутри фрейма запрещён. Этого достаточно для наших заранее подготовленных HTML и CSS, но для выполнения произвольного чужого кода понадобится отдельная изоляция.

Браузер уже умеет разбирать CSS

Если спросить у getComputedStyle() цвет заголовка, ответ может выглядеть как rgb(255, 0, 0), хотя в исходнике было red. Поэтому сравнение с исходной строкой снова не годится. Читаем значение у обоих элементов в одном браузере.

При этом название метода немного обманчиво. Он возвращает итоговые значения, которые CSSOM называет resolved values: для части свойств это вычисленные значения, для части результат зависит от раскладки. Эту разницу описывает CSSOM. Для сравнения мы одинаково готовим оба документа и читаем одинаковые свойства.

В примере red и rgb() приводятся к одинаковой записи. Это не универсальное сравнение любых цветов: значения из разных цветовых пространств могут сохранить разную форму записи. Если упражнение разрешает такие цвета, нужно заранее выбрать пространство сравнения и допустимое отклонение. Сравнение строк этого за нас не сделает.

У рамки сравним каждую сторону отдельно. Сокращённое border объединяет несколько свойств, и по общему несовпадению трудно понять причину. Если ученик сделал левую сторону синей, сообщение «неправильная рамка» заставит его заново просматривать всю запись. border-left-color сразу указывает место.

Вычисленный цвет нужно брать у заголовка, потому что условие относится к тексту. Красный #card ещё ничего не гарантирует: у h1 может быть собственное правило с синим цветом. Зато красный цвет, унаследованный от #stage, нас устраивает. По результату нельзя узнать, в каком правиле ученик написал декларацию, и в этом упражнении знать это не требуется.

До создания образца проверим собственные ожидаемые значения. Для них заведём объект в check.js:

export const expected = {
  color: 'red',
  border: '1px solid red',
  transform: 'translateX(12px) rotate(90deg)'
};

export function validateExpected(values, css) {
  for (const [property, value] of Object.entries(values)) {
    if (!css.supports(property, value)) {
      throw new Error(`Неверное условие проверки: ${property}: ${value}`);
    }
  }
}

Перед запуском вызываем validateExpected(expected, CSS), а значения этого объекта подставляем в CSS эталона. Если вместо red автор написал redd, проверка останавливается с ошибкой условия. Иначе браузер отбросит декларацию, и мы рискуем сравнить два одинаковых значения по умолчанию.

CSS.supports() помогает найти недопустимую запись. Синий цвет вместо красного он пропустит: оба значения поддерживаются. Соответствие эталона заданию всё равно проверяет автор задания. Здесь мы используем конкретные значения, без переменных CSS, которые могут оказаться неопределёнными в документе.

Матрицы сравниваем как числа

У преобразований появляется ещё одна трудность. Мы написали поворот и перенос, а браузер вернул матрицу:

matrix(0, 1, -1, 0, 12, 0)

У эквивалентной записи рядом с нулём может оказаться очень маленькое число. Сравнение строк потребует буквального совпадения, хотя разница возникла при вычислениях. Я бы не округлял всю матрицу до одного количества знаков: её коэффициенты имеют разный смысл.

Первые четыре числа описывают линейную часть преобразования. Последние два задают перенос в CSS-пикселях. Для нашей карточки примем погрешность 0.000001 у линейных коэффициентов и 0.02 пикселя у переноса. На размере 120 пикселей первое отклонение даёт намного меньше сотой пикселя. При этом лишний пиксель сдвига уже не пройдёт проверку.

Это допуски конкретного упражнения с известными размерами, не константы для любых преобразований. Для элемента шириной в миллион пикселей тот же допуск коэффициента может дать заметную ошибку положения.

Добавим в check.js:

export function close(actual, expected, tolerance) {
  return Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
}

export function sameMatrix(actual, expected) {
  if (!actual.is2D || !expected.is2D) {
    return false;
  }

  const linear = ['a', 'b', 'c', 'd'];
  const shift = ['e', 'f'];

  return (
    linear.every(key => close(actual[key], expected[key], 0.000001)) &&
    shift.every(key => close(actual[key], expected[key], 0.02))
  );
}

Строку преобразования разберёт DOMMatrixReadOnly. Устройство матриц и смысл этих полей описаны в Geometry Interfaces. Трёхмерные преобразования мы намеренно не принимаем: упражнение посвящено преобразованию на плоскости. Расширить набор допустимых решений можно, но тогда нужно определить и проверить новые условия.

Одинаковая матрица ещё не означает одинаковое положение

Оставим transform неизменным, но заменим transform-origin: 0 0 на 50% 50%. Матрица, полученная из свойства transform, останется той же, а карточка повернётся вокруг другой точки. Если смотреть только на матрицу, проверка примет неправильный результат.

Похожий случай получается с шириной родителя. При ширине 480 пикселей карточка вдвое шире. Поворот по-прежнему правильный, но после него высота видимого прямоугольника тоже станет вдвое больше.

Поэтому дополнительно измерим результат. getBoundingClientRect() даёт прямоугольник относительно области просмотра с учётом преобразований. Он охватывает элемент и не повторяет его контур при произвольном повороте. Нам нужен именно такой прямоугольник для заданного поворота на 90 градусов; поведение метода определено в CSSOM View.

Внутри iframe метод возвращает координаты относительно области просмотра самого фрейма: его положение на внешней странице в них не входит. Нам же важно положение карточки относительно её родителя, поэтому вычтем его координаты:

export function measure(card, stage) {
  const box = card.getBoundingClientRect();
  const parent = stage.getBoundingClientRect();

  return {
    left: box.left - parent.left,
    top: box.top - parent.top,
    width: box.width,
    height: box.height
  };
}

В нашем образце получится прямоугольник шириной 40 и высотой 120 пикселей. Его левая граница находится на 28 пикселей левее начала родителя: при повороте высота карточки уходит влево, а перенос возвращает 12 пикселей вправо. Это полезно посчитать заранее. Иначе ошибка в образце будет выглядеть как странное поведение браузера.

Сравниваем каждую величину с допуском 0.02 CSS-пикселя. Такое измерение не доказывает, что элементы одинаково выглядят: например, оно не заметит полупрозрачность или перекрытие соседним элементом. В задании они не меняются. Если это часть доступного ученику решения, придётся добавить соответствующие условия, а не объявлять совпадение прямоугольников полной проверкой вёрстки.

Закрывающий тег ищем до исправлений браузера

К исходному HTML стоит вернуться отдельно. innerHTML получившегося элемента уже сериализует DOM. Закрывающий тег, появившийся в такой строке, не доказывает, что ученик его написал.

Поиск </h1> в исходнике тоже ненадёжен: эта последовательность может оказаться в комментарии или внутри значения атрибута. Для такого различия нужен разбор HTML. Здесь оправдан готовый парсер: писать собственный разборщик ради одного правила получится существенно сложнее самого правила.

В серверной части примера использую parse5 с координатами исходного кода. Если тег действительно написан, у узла есть информация о его закрывающем теге. Документация parse5 отдельно описывает эту возможность и узлы, которые парсер создаёт сам.

Файл source.js работает в Node.js. Для него нужна зависимость parse5; в примере используется версия 8.0.1. Результат этого разбора передаётся браузерной проверке вместе с исходным решением.

import { parseFragment } from 'parse5';

export function sourceIssues(html) {
  const root = parseFragment(html, { sourceCodeLocationInfo: true, scriptingEnabled: false });
  const issues = [];

  function visit(node) {
    if (node.tagName === 'h1' && !node.sourceCodeLocation?.endTag) {
      issues.push({
        rule: 'source',
        expected: 'Закрывающий тег </h1>',
        actual: `Не найден, строка ${node.sourceCodeLocation?.startLine ?? '?'}`
      });
    }

    for (const child of node.childNodes ?? []) {
      visit(child);
    }
  }

  visit(root);
  return issues;
}

Функция проверяет только закрывающий h1. Правила закрытия других элементов могут отличаться: например, </p> в определённых условиях разрешено пропускать. У h1 такого разрешения нет.

Если заголовка вообще нет, функция вернёт пустой список. Его отсутствие заметит следующая проверка, которая работает с DOM. Так одна и та же ошибка не появится в двух местах под разными названиями.

Параметр scriptingEnabled: false согласует разбор с нашим фреймом, в котором JavaScript выключен. Это влияет и на noscript: его содержимое становится разметкой. Если оставить у парсера режим с включёнными скриптами, он прочитает это содержимое как текст и пропустит незакрытый заголовок, который браузер затем покажет.

Собираем проверку

В check.js уже есть функции сравнения матриц и измерения. Осталось добавить inspect(). Она получает документ решения, документ эталона и замечания к исходному HTML. В ответе будет список несовпадений; пустой список означает, что все выбранные условия выполнены.

export function inspect(document, reference, source) {
  const issues = [...source];
  const cards = document.querySelectorAll('#card');
  const stages = document.querySelectorAll('#stage');
  const headings = document.querySelectorAll('h1');

  if (
    cards.length !== 1 ||
    stages.length !== 1 ||
    headings.length !== 1 ||
    cards[0].parentElement !== stages[0] ||
    headings[0].parentElement !== cards[0] ||
    headings[0].textContent.trim() !== 'Карточка'
  ) {
    issues.push({
      rule: 'dom',
      expected: 'Одна карточка в #stage и один её заголовок h1 с текстом «Карточка»',
      actual: 'Структура отличается'
    });

    return issues;
  }

  const card = cards[0];
  const stage = stages[0];
  const heading = headings[0];
  const target = reference.querySelector('#card');
  const view = document.defaultView;
  const model = reference.defaultView;
  const style = view.getComputedStyle(card);
  const sample = model.getComputedStyle(target);
  const sides = ['top', 'right', 'bottom', 'left'];
  const parts = ['width', 'style', 'color'];

  function compare(rule, actual, expected) {
    if (actual !== expected) {
      issues.push({ rule, expected, actual });
    }
  }

  compare(
    'color',
    view.getComputedStyle(heading).color,
    model.getComputedStyle(reference.querySelector('h1')).color
  );

  for (const side of sides) {
    for (const part of parts) {
      const property = `border-${side}-${part}`;
      compare(property, style.getPropertyValue(property), sample.getPropertyValue(property));
    }
  }

  const matrix = new view.DOMMatrixReadOnly(style.transform);
  const wanted = new model.DOMMatrixReadOnly(sample.transform);

  if (!sameMatrix(matrix, wanted)) {
    issues.push({
      rule: 'transform',
      expected: sample.transform,
      actual: style.transform
    });
  }

  const box = measure(card, stage);
  const bounds = measure(target, reference.querySelector('#stage'));

  for (const key of Object.keys(bounds)) {
    if (!close(box[key], bounds[key], 0.02)) {
      issues.push({ rule: `geometry-${key}`, expected: bounds[key], actual: box[key] });
    }
  }

  return issues;
}

Если нужного элемента нет, мы возвращаем замечание о структуре и не пытаемся читать у него стили. Неправильный цвет попадёт в результат как несовпадение свойства. А недопустимое значение в expected, например color: redd, остановит запуск ещё до проверки решения. У этих случаев разные причины, и ученику не следует получать одну и ту же красную отметку.

В интерфейсе из замечания border-left-color можно сделать сообщение «Левая сторона рамки синяя, ожидается красная». Сырые значения тоже полезны при разработке задания. Например, matrix(0, 1, -1, 0, 13, 0) рядом с ожидаемым переносом 12 сразу объясняет несовпадение.

В браузере остаётся соединить эти части. data содержит HTML и CSS эталона, а item содержит один вариант решения вместе с замечаниями sourceIssues(), полученными на сервере:

const reference = await frame(data.html, data.css);

try {
  const current = await frame(item.html, item.css);

  try {
    const issues = inspect(current.contentDocument, reference.contentDocument, item.issues);
    console.table(issues);
  } finally {
    current.remove();
  }
} finally {
  reference.remove();
}

После измерений фреймы удаляются. Проверка закончилась; хранить все предыдущие документы ради следующего изменения кода незачем. При переборе нескольких решений можно использовать один эталон и удалить его после последней проверки.

Теперь проверим саму проверку

Один правильный образец даёт слишком мало уверенности. Ошибочный тест, который всегда возвращает true, тоже его примет. Поэтому рядом с образцом нужны варианты, различающиеся ровно тем, что мы собираемся проверять.

Для проверки я использовал такие варианты:

Изменение относительно образца Ожидаемый результат
Без изменений Принять
Рамка задана длинными свойствами Принять
Красный записан через rgb() Принять
Цвет заголовка унаследован от родителя Принять
Матрица с допустимым отклонением коэффициента на 0.0000001 Принять
У h1 нет закрывающего тега Ошибка исходника
</h1> присутствует только в комментарии Ошибка исходника
Незакрытый h1 находится внутри noscript Ошибка исходника
Вместо h1 написан p Ошибка структуры
В заголовке другой текст Ошибка структуры
У заголовка собственный синий цвет Ошибка цвета
Левая сторона рамки синяя Ошибка border-left-color
Перенос на 13 вместо 12 пикселей Ошибка преобразования и положения
Родитель вдвое шире Ошибка размера
Другая точка преобразования Ошибка положения
content-box вместо border-box Ошибка размера и положения

Важно проверять и причину отказа. Если незакрытый заголовок не прошёл из-за случайно изменённого цвета, правило для HTML всё ещё может быть сломано. Поэтому у отрицательных примеров записан ожидаемый код замечания. Совпасть должны и отказ, и нужная причина.

Отдельные случаи оставлены для первых двух ошибочных тестов. Поиск строки отвергает длинные свойства, а поиск h1 в DOM принимает незакрытый заголовок. Это ожидаемые ошибки старых проверок, а не неудача нового модуля. Ещё один случай проверяет, что color: redd вызывает ошибку автора условия.

Для исходника есть и более узкие проверки в Node.js: ложный закрывающий тег в атрибуте, HTML внутри script и допустимый пропуск </p>. Их можно выполнить без раскладки. Подменять ими браузерные проверки нельзя: Node.js не вычислит размер повёрнутой карточки.

Всего в браузерном наборе 19 сценариев: 16 вариантов решения, два случая для старых ошибочных тестов и опечатка в условии автора. Отдельно есть шесть проверок разбора исходника и числового сравнения в Node.js. Перед использованием проверки стоит выполнить браузерный набор в каждом браузере, для которого предназначено упражнение. Эталон в том же браузере помогает сравнивать вычисленные значения в одинаковой записи, но не устраняет различия в поддержке и реализации свойств.

Для меня такие примеры становятся частью самого задания. Разрешаем наследование цвета? Значит, сохраняем правильное решение с наследованием. Учим писать закрывающий тег? Добавляем исходник, в котором его отсутствие исправил браузер. Когда меняется проверяющий код, по этим примерам видно, какое решение он перестал принимать. И можно разобраться, изменилось ли наше требование или мы ошиблись в проверке.