Телефон для коня и оркестр без пианиста. Как придумать спортивные задачи по фронтенду

Привет! Меня зовут Дмитрий Андриянов, я работаю разработчиком интерфейсов в Яндексе. В прошлом году я участвовал в подготовке нашего онлайн-соревнования по фронтенду.

xuwsqcae8zkjcjh0bkijqxun8z8.png
Пару дней назад мне пришло письмо от организаторов с вопросом, не хочу ли я поучаствовать снова — придумать задачи по фронтенду для второго чемпионата по программированию. Я согласился — и подумал, что это интересная тема для статьи. Налейте кофе, усаживайтесь поудобнее. Я расскажу, как мы готовили задачи год назад.


Нас было около десяти человек, почти все — фронтенд-разработчики из разных сервисов Яндекса. Нам предстояло сделать подборку задач, которые проверялись бы автотестами.

Для соревнований по программированию есть специальный сервис — Яндекс.Контест. Там можно публиковать задания, а участники регистрируются и решают их. Проверка заданий происходит автоматически, результаты участников публикуются в специальной таблице. Таким образом, инфраструктура уже была готова. Требовалось только придумать задачи. Но оказалось, что есть один нюанс. Раньше Яндекс проводил соревнования по алгоритмам, машинному обучению и другим темам, а по фронтенду — ни разу. Ни у кого не было понимания, из чего должно состоять соревнование и как автоматизировать проверку.

v9d05absifxraifxjqgxkyojcoy.jpeg

Мы решили, что для фронтендеров подойдут задачи, в которых нужны верстка, JavaScript и знание API браузера. Верстку можно проверять сравнением скриншотов. Алгоритмические задачи можно запускать в Node.js и проверять, сравнивая результат с правильным ответом. Программы, работающие с API браузера, можно запускать через Puppeteer и скриптом проверять состояние страницы после выполнения.

Соревнования состоят из двух раундов — квалификации и финала, по 6 задач в каждом раунде. Квалификационные задачи должны быть вариативными, чтобы разным участникам достались разные варианты. Мы выбрали количество и тип задач для каждого раунда, поделились на команды по два человека и распределили задачи между командами. Каждой группе нужно было придумать две вариативные задачи для квалификации и две невариативные для финала.

zhbxrslglxdwuk_fdcvjww-cz3o.jpeg


Давайте кликать по DOM-элементам…

Появилась идея — в качестве одного из вариативных заданий дать браузерную игру, в которой нужно кликать по DOM-элементам. Задачей участника было написать программу, которая играет в эту игру и выигрывает. Придумали 4 варианта:

Если хотите, можете перейти по ссылкам и поиграть. Если будете играть в «телефон» или «пианино», не забудьте включить звук.

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

// все элементы — это блоки div с определенными классами
// targetClasses — набор классов для информационных элементов
// keyClasses — набор классов для элементов, по которым нужно кликать
function initGame(targetClasses, keyClasses) {

    // рендерим информационные элементы
    for(let i = 0; i < targetClasses.length; i++) {
        document.body.insertAdjacentHTML('afterbegin', `
`); } // рендерим кликабельные элементы for(let i = 0; i < keyClasses.length; i++) { document.body.insertAdjacentHTML('beforeend', // data-index пригодится для обработки кликов `
`); } // на самом деле код был немного другой, но суть та же }

Внешним видом управляли через CSS. Получилось очень похоже на csszengarden.com — одна верстка с разными стилями выглядит по-разному.

yhsjlimr8bqrhl-6mglptyeop44.png
8mryztu4x5p5bt2irv1rgkjaraq.png
bl75oigc4f2wswzkz_txcek01ky.png
u4h13wk7izouclbf4q8vnh2dm7e.png

Результат работы программы участника — это лог кликов по элементам. Добавили обработчик, который записывает информацию о кликнутых элементах в глобальную переменную. Чтобы участник вместо честных кликов не мог сразу записать результат в эту переменную, мы передаем её название снаружи.

function initGame(targetClasses, keyClasses, resultName) {
    // ...
    const log = [];
    document.body.addEventListener('click', (e) => {
        if (e.target.classList.contains('key')) {
            // если кликнули по кликабельному элементу, 
            // то записываем в лог его номер
            log.push(e.target.data.index);

            // если кликнули столько раз, сколько информационных элементов
            // на странице, то кладем лог в глобальную переменную
            if (log.length === targetClasses.length) {
                window[resultName] = log;
            }
        }
    });
}

Cкрипт запуска программы участника был примерно таким:

// В отличие от кода игры, который работает в браузере,
// этот скрипт запускается в Node.js.
// Он запускает Chrome в headless-режиме, открывает в нем 
// страницу с игрой и подключает скрипт участника.

const puppeteer = require('puppeteer');
const { writeFileSync } = require('fs');

const htmlFilePath = require.resolve('./game.html'); // файл с игрой
const solutionJsPath = resolve(process.argv[2]);     // программа участника

const data = require('input.json');                  // входные данные теста
const resName = `RESULT${Date.now()}`;               // случайное название глобальной переменной

(async () => {
    const browser = await puppeteer.launch();          // запускаем браузер
    const page = await browser.newPage();              // открываем новую вкладку

    await page.goto(`file://${htmlFilePath}`);         // открываем в ней файл с игрой

    await page.evaluate(resName => initGame(           // инициализируем игру
        data.target, data.keys, resName), resName);

    await page.addScriptTag({ path: solutionJsPath }); // подключаем решение участника
    await page.waitForFunction(`!!window[${resName}]`) // ждем, пока не появится переменная resName

    const result = await page.evaluate(`window[${resName}]`);   // получаем результат
    writeFileSync('output.json', JSON.stringify(result));       // и записываем его в выходной файл

    await browser.close();
})();


Добавим звук

Мы решили, что нужно немного оживить игру с телефоном и добавить звук нажатий клавиш. Такие звуки называются DTMF-тонами. Нашли статью о том, как их генерировать. Если кратко, необходимо одновременно проигрывать два звука с разной частотой. Звуки заданной частоты можно проигрывать с помощью Web Audio API. Получился примерно такой код:

function playSound(num) {

    // создаем audioContext
    const context = this.audioContext;
    const g = context.createGain()

    // настраиваем первый генератор звука
    const o = context.createOscillator();
    o.connect(g);
    o.type='sine';
    o.frequency.value = [697, 697, 697, 770, 770, 770, 852, 852, 852, 941, 941][num];
    g.connect(context.destination);

    // настраиваем второй генератор звука
    const o2 = context.createOscillator();
    o2.connect(g);
    o2.type='sine';
    o2.frequency.value = [1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336][num];
    g.connect(context.destination);

    // магические числа — это значения частоты из таблицы
    // см. статью по ссылке

    // включаем звук
    o.start(0);
    o2.start(0);

    // выключаем через 240 мс
    g.gain.value = 1;
    setTimeout(() => g.gain.value = 0, 240);
}

Для игры с пианино тоже добавили звуки. Если бы кто-нибудь из участников попробовал сыграть написанные на странице ноты, он услышал бы имперский марш из Star Wars.

5z02newyi1chpmu1llmrlgatqbm.jpeg


Усложним задание

Мы радовались тому, какое классное задание со звуками у нас получилось, но радость длилась недолго. Во время тестирования игры оказалось, что программа очень быстро кликает по кнопкам и все наши классные звуки сливаются в общую кашу. Мы решили добавить задержку 50 мс между нажатиями клавиш, чтобы звуки проигрывались по очереди. Заодно это немного усложнило задание.

function initGame(targetClasses, keyClasses, resultName) {
    // в этой переменной будем запоминать 
    // время последнего клика
    let lastClick = 0;
    // ...
    document.body.addEventListener('click', (e) => {
        const now = Date.now();

        // если с момента последнего клика 
        // еще не прошло 50 мс, то игнорируем клик
        if (lastClick + 50 < now) {
            // ...

            // запоминаем новое время клика
            lastClick = now;
        }
    });
}

Но и это не всё. Мы подумали, что участники могут легко посмотреть исходный код и сразу увидеть задержку. Чтобы усложнить им задачу, мы минифицировали весь JS-код на странице с помощью UglifyJS. Но эта библиотека не меняет публичный API классов. Поэтому те части, которые UglifyJS оставил прежними (а именно названия методов и полей классов), мы заменили через replace.

Скрипт для обфускации игры выглядел примерно так:

const minified = uglifyjs.minify(lines.join('\n'));

const replaced = minified.code
    .replaceAll('this.window', 'this.шахматы')
    .replaceAll('this.document', 'this.цапля')
    .replaceAll('this.log', 'this.мыш')
    .replaceAll('this.lastClick', 'this.табло')
    .replaceAll('this.target', 'this.библиотека')
    .replaceAll('this.resName', 'this.глинтвейн')
    .replaceAll('this.audioContext', 'this.зоопарк')
    .replaceAll('this.keyCount', 'this.лось')
    .replaceAll('this.classMap', 'this.конь')
    .replaceAll('_createDiv', 'подоить_козу')
    .replaceAll('_renderTarget', 'поесть_сена')
    .replaceAll('_renderKeys', 'помыть_слона')
    .replaceAll('_updateLog', 'погладить_кота')
    .replaceAll('_generateAnswer', 'бобры')
    .replaceAll('_createKeyElement', 'гироскутер')
    .replaceAll('_getMessage', 'выхухоль')
    .replaceAll('_next', 'сделать_вступительные_задания_школы_разработки_интерфейсов')
    .replaceAll('_pos', 'мычать_как_корова')
    .replaceAll('PhoneGame', 'кавалерия')
    .replaceAll('MusicGame', 'паяльник')
    .replaceAll('BaseGame', 'xyz');


Напишем креативное условие

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

Мой любимый вид юмора — абсурд. Это когда с серьезным видом говоришь какую-то нелепую чушь. Чушь обычно звучит неожиданно и вызывает смех. Я хотел сделать условия задач абсурдными, чтобы порадовать участников. Так появилась история про коня Адольфа, который не может позвонить другу, потому что не попадает своими большими копытами по клавишам телефона.

quef5tswcc2ikg-4iqybeazherg.jpeg

Потом появилась история про девочку, которая занимается на пианино и хочет это автоматизировать, чтобы вместо занятий пойти гулять. Там была фраза «Если девочка перестает играть, из комнаты приходит мама и дает подзатыльник». Нам сказали, что это пропаганда детского насилия и нужно написать другой текст. Тогда мы придумали историю про оркестр, в котором перед концертом заболел пианист, а один из музыкантов пишет программу на JS, которая исполнит его партию.

В целом нам удалось добиться нужного эффекта от текстов. Если хотите, можете прочитать их по ссылке.


Настройка задач в Контесте

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

v4pxl3ikfi0gpiloyn6p8g0bbnq.png

На вход первого этапа поступает набор тестовых данных и программа участника. Внутри работает скрипт run.js, код которого мы написали выше. Он отвечает за то, чтобы запустить программу участника, получить и записать в файл результат её работы. Выполнение программы происходит в отдельной виртуальной машине, которая поднимается из Docker-образа перед запуском. Эта виртуальная машина ограничена в ресурсах, у неё нет доступа в сеть.

Второй этап (проверка результата) выполняется в другой виртуальной машине. Таким образом, у программы участника физически нет доступа в окружение, где происходит проверка. На вход второго этапа подается результат работы программы участника (полученный на первом этапе) и файл с правильным ответом. На выходе — exit code проверяющего скрипта, по которому Контест понимает, чем закончилась проверка:

OK = 0,
PE (presentation error — неправильный формат результата) = 4
WA (wrong answer) = 5
CF (ошибка при проверке) = 6

Контест был плохо приспособлен к задачам по фронтенду, в том числе нельзя было использовать Node.js. Мы решили проблему, упаковав скрипты проверки в бинарный файл при помощи pkg вместе с Node.js и node_modules. Теперь мы обладаем тайными знаниями о Контесте и при подготовке нынешнего чемпионата испытываем гораздо меньше сложностей.


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

Сейчас вместо соревнований по отдельным направлениям мы проводим единые чемпионаты по программированию, где просто есть параллельные треки, включая фронтенд.

Я ни капельки не жалею о времени, потраченном на подготовку задач. Было интересно и весело, нешаблонно. В одном из комментариев на Хабре написали, что условия были придуманы энтузиастами своего дела. Во время соревнования классно было осознавать, что участники решают задачи, которые придумывал ты.

Ссылки:
— Разбор прошлогоднего задания по фронтенду, которое мы подготовили
— Разбор трека по фронтенду в первом чемпионате этого года

© Habrahabr.ru