Работает ли это? А что, если… ? Как настоящие QA, мы всегда задаемся этими вопросами. Неудивительно, что вся наша команда взбудоражилась, увидев рилс про «фейковую бесконечность» прокрутки в будильнике на iPhone. Неужели правда? Конечно, тут же проверили на тестовых айфонах. Оказалось, часы и минуты в iOS-будильнике действительно не цикличны. Это список, который можно быстро долистать до конца.

Нам стало интересно, баг ли это, откуда он в iOS и есть ли такое в Android. Чтобы разобраться и найти ответы, нам даже пришлось «на коленке» написать автотест на C#. Но обо всем по порядку.

Почему «заканчивается» прокрутка в будильнике на iPhone

Мы привыкли думать, что колесо выбора времени в iOS — зацикленный механизм и можно вечно крутить барабан в любую сторону. Недавно нашей команде попался рилс, где утверждали, что бесконечность прокрутки в будильнике на iPhone фейковая.

Мы тут же проверили на тестовых айфонах: действительно, если скроллить часы и минуты в будильнике чуть дольше обычного, можно долистать их до конца. Прокрутка останавливается на 4:39 PM (или 16:39).

Это не баг, а архитектурная особенность со времен первого iPhone. Под капотом iOS-будильника скрывается таблица (UITableView), которая содержит список заданных значений. Ходят слухи, что в нем 10 000 элементов (но это не точно), и именно конечностью списка объясняется ограниченность прокрутки в будильнике.

Интересно, что при скролле часов и минут в памяти будильника держится не вся таблица, а всего 5–7 ячеек. Это также наследие первого iPhone, который не мог быстро и плавно обрабатывать сотни элементов в табличном представлении. Чтобы повысить производительность и улучшить отзывчивость приложения, разработчики придумали reusable cells, который стал основным инструментом для создания любого прокручиваемого списка на iOS. Reusable cells позволяет интерфейсу не создавать новые экземпляры ячеек для каждой строки или элемента, а повторно использовать те, что при скролле ушли за пределы видимости.

В активной памяти будильника хранится всего 5–7 ячеек таблицы, которые при скролле переиспользуются и хитрой последовательностью подставляются с другой стороны. Как это работает→
В активной памяти будильника хранится всего 5–7 ячеек таблицы, которые при скролле переиспользуются и хитрой последовательностью подставляются с другой стороны. Как это работает→

Есть ли такая архитектурная особенность в Android 

Нам стало интересно: есть ли такое архитектурное ограничение в Android? В сети пишут, что Android-будильник остановится после 264 полных оборотов.

Почему именно 264? Это число ничем не обусловлено. Логичнее предположить, что предел Android-будильника — 255 полных оборотов (из-за переполнения байта).

Мы посчитали, что утверждение звучит, как вызов, и решили его проверить. «Чистый» Android для эксперимента не подходил: начиная с 7-й версии (а может и раньше), для установки будильника в нем используется круговой селектор с настоящим циклическим алгоритмом и лимит прокрутки возможен, только если его туда специально заложили.

Ограничения архитектуры могли обнаружиться в кастомных версиях Android, поэтому мы взяли тестовые Samsung с One UI и Xiaomi с MIUI и HyperOS, где UI будильников выглядит так же, как в iPhone.

Сначала попробовали протестировать руками — непрерывно скроллили колесо выбора времени около трех минут. Пальцы устали, а до целевого числа прокруток было еще далеко. Мы не захотели сдаваться и решили автоматизировать проверку, быстро собрав UI-автотест.

UI-автотест за 30 минут

На клиентских проектах чаще всего мы пишем автотесты на Python или Java — сейчас это наиболее актуальные для решения большинства задач по автоматизации тестирования языки программирования.

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

Мы подключили Samsung Galaxy A55 к компьютеру по ADB, запустили Appium для управления тестами и Android SDK для работы с устройством и начали колдовать. Через 30–40 минут собрали и отладили полноценный автотест, который умеет крутить часы и минуты Android-будильника в любую сторону сколько нужно раз.

Автотест запускается из консоли. Вот как он работает:

1. Запускает ADB-сервер, ��одключается к Samsung Galaxy A55 по дебаг-порту и получает его ID через команду adb devices.

2. Запускает Appium на локальном хосте и через ID связывает Appium driver (UIAutomator2) с телефоном.

3. Активирует пакет com.sec.android.app.clockpackage («Часы» Samsung), переходит на вкладку «Будильник» и кликает на первую карточку будильника.

4. Находит контейнер с часовыми обозначениями и через драйвер анимации каждые 250 мс посылает туда жест «свайп вверх». По вертикали (ось Y) свайп тянется от нижнего до верхнего элемента ряда, а по горизонтали (ось X) сохраняет положение посередине объекта. Таймаут в 250 мс нужен, чтобы прокрутка элементов успела завершиться перед следующим свайпом. Полный оборот колеса с часами автотест совершает примерно за 2,5 свайпа.

//Код создания свайпа

public void SwipeUp(By elem, int sequentialNum = 1, int sequentialTimeout = 0)
    {
        //Получаем элемент, который будем крутить
        WebElement root = _driver.FindElement(elem);

        //Ищем центр координат элемента по оси X 
        int rootCenter = root.Coordinates.LocationInDom.X + (root.Size.Width / 2);

        //Обозначаем старт и конец движения внизу и вверху элемента по Y с небольшим отступом
        double swipeStart = root.Coordinates.LocationInDom.Y + (root.Size.Height * 0.8);

        double swipeEnd = root.Coordinates.LocationInDom.Y + (root.Size.Height * 0.2);

        //Инициализируем тип девайса ввода (тач пальцем по экрану) и последовательность действий
        PointerInputDevice finger = new(PointerKind.Touch);
        ActionSequence swipe = new(finger, 1);

        //Добавляем порядок действий в последовательность
        swipe.AddAction(finger.CreatePointerMove(CoordinateOrigin.Viewport, rootCenter, (int)swipeStart, TimeSpan.Zero));
        swipe.AddAction(finger.CreatePointerDown(MouseButton.Touch));
        swipe.AddAction(finger.CreatePointerMove(CoordinateOrigin.Viewport, rootCenter, (int)swipeEnd, TimeSpan.FromMilliseconds(100)));
        swipe.AddAction(finger.CreatePointerUp(MouseButton.Touch));
        swipe.AddAction(finger.CreatePause(TimeSpan.FromMilliseconds(sequentialTimeout)));


        //Выполняем последовательность действий n раз
        for (int i = 0; i < sequentialNum; i++)
        {
            _driver.PerformActions(new List<ActionSequence> { swipe });
        }
    }

5. Каждые семь свайпов (для экономии ресурсов железа) скрипт проверяет средний элемент ряда. Если значение элемента не меняется три раза подряд, автотест считает, что прокрутка дошла до конца, выводит в консоль сообщение «Список кончился» и завершает работу.

Если значения в среднем элементе ряда меняются, автотест продолжает проверку до тех пор, пока не выполнит 1 000 ± 125 свайпов вверх (с учетом подлагов анимации).

Правильно было бы сделать один метод вызова свайпов с флагом на необходимость проверки, который каждый седьмой свайп выставлялся бы в true, а остальные разы в false. Но для быстрой реализации мы выбрали костыль и написали два метода вызова свайпов: обычный (код после пункта №4) и с проверкой (ниже), который запускается после шести обычных свайпов.

//Код для вызова свайпа с проверкой

    public void SwipeHourUp(int times = 1, int timeout = 1)
    {
        //Вызываем метод на получение данных из левого круга (внутри самого метода используется локатор центрального бокса с числами)
        string leftNPValue = GetLeftNPValue();
        
        //Проводим свайп
        SwipeUp(leftNP, times, timeout);
        
        //Проверяем, изменилось ли значение. Само эталонное значение не меняем, т. к. при конце списка все совпадения будут с ним
        int failCount = 0;
        while (GetLeftNPValue() == leftNPValue && failCount != 3)
        {
            failCount = failCount++; // Сначала инкрементируем, т. к. первый свайп уже произошел
            SwipeUp(leftNP, times, timeout);
        }
        
        //Если значение не изменилось после трех свайпов - список кончился, вызываем метод создания искусственного исключения с соответствующим сообщением
        if (failCount == 3)
        {
            ThrowWIPException("Список кончился");
        }
    }

6. Завершив проверку, автотест по кнопке «Отмена» выходит из карточки будильника, чтобы сбросить все изменения, и заходит обратно.

7. Делает ещё ~1 000 свайпов, но вниз (инверсия по Y), каждые семь свайпов проверяя средний элемент ряда.

8. Закрывает приложение и отключает все соединения (Appium, ADB).

Конечно, будь это серьезный коммерческий проект, мы бы написали более чистый и оптимальный код. Также сделали бы автотест более удобным и стабильным, обернув всё в POM (Page Object Model) и добавив конфигурации, работу с окружением, обработку исключений и ожиданий объектов. Но для пет-проекта, собранного максимально быстро из того, что было, чтобы удовлетворить любопытство, хватило самого минимума.

Результаты автотеста

Автотест прокрутил колесо часов в Android-будильнике по 450 раз в каждую сторону, что больше загадочных 264 полных оборотов, про которые писали в сети. Теперь мы с уверенностью можем сказать, что в Android реализован полноценный циклический алгоритм, а не конечный список, как в iOS.

Наше любопытство привело к тому, что ради проверки сомнительной интернет-теории мы за вечер написали автотест. В старой поговорке Варваре из-за любопытства отрывают нос, а мы считаем, что это базовое качество хорошего QA. Оно помогает нашей команде глубоко погружаться в проекты, находить причины даже самых странных проблем и неординарно решать задачи. Если заходит наш дотошный подход и хочется узнать больше о том, чем мы можем быть полезны, добро пожаловать на наш сайт.