Частые ошибки
Семь ошибок, которые компилируются, работают и часто проходят первый тест. Каждый раздел показывает ошибку, говорит, что идёт не так, показывает правильный способ в несколько строк и ссылается на раздел, где ошибка показана целиком. О большинстве из них сообщает в коде плагин анализатора solo_lints.
Каждое ожидание на своём месте
Заголовок раздела «Каждое ожидание на своём месте»Простой await в теле не служит контрольной точкой.
Так не делайте:
// Неверно: отменённая загрузка продолжает писать.for (final chunk in chunks) { await device.write(chunk);}Отменённая задача идёт дальше до первой контрольной точки, и close() ждёт
столько же: загрузка, отменённая на втором из четырёх кусков, пишет и третий,
и четвёртый. Ждите через контекст. ctx.join дожидается вызова и бросает
Cancelled вместо его результата; ctx.abandonable бросает сразу и оставляет
вызов работать.
Делайте так:
for (final chunk in chunks) { // Проверяет и до записи, и после неё. await ctx.join(() => device.write(chunk));}В уборке всё наоборот. Она идёт после тела, где join бросает StateError.
Так не делайте:
// Неверно: сброса не происходит.ctx.onDispose(() async { // Сброс должен закончиться до продвижения очереди. await ctx.join(() => device.flush());});Буфер устройства остаётся несброшенным, чем бы задача ни кончилась, и при
Done тоже. О StateError узнаёт хук onError контроллера, а затем ошибка
уходит в Solo.unansweredHandler или, если обработчик не задан, в зону.
В уборке ждите простым await.
Делайте так:
ctx.onDispose(() async { // Уборка идёт после тела, где методов ожидания уже нет: // здесь работает обычный await. await device.flush();});Буфер сбрасывается, чем бы загрузка ни кончилась. Подробнее в разделе «Обычный await и время жизни контекста» страницы об отмене.
Освобождение на вызове
Заголовок раздела «Освобождение на вызове»Ресурс можно потерять, если его освобождение регистрируют строкой ниже вызова, который его приносит.
Так не делайте:
// Неверно: отмена влезает между открытием и onDispose.final db = await ctx.join(Database.open);ctx.onDispose(db.close);Отмена во время открытия бросает раньше второй строки, и открытая база теряется. Передайте освобождение вызову, который приносит ресурс.
Делайте так:
final db = await ctx.join(Database.open, dispose: (db) => db.close());Значение, которое не дошло до тела, освобождается на месте, а дошедшее освобождается, когда задача кончится. Подробнее в разделе «Получение ресурса вызовом» страницы о ресурсах.
Пропустить отмену через catch
Заголовок раздела «Пропустить отмену через catch»Cancelled реализует Exception, поэтому широкий catch ловит его вместе
с ошибками, ради которых его писали.
Так не делайте:
// Неверно: отмена сбрасывает исправную камеру.try { await ctx.join(hw.open); await ctx.join(() => hw.setZoom(zoom));} on Object catch (error) { await hw.reset(); ctx.emit(Broken(error)); rethrow;}Задача, отменённая во время setZoom, сбрасывает устройство, которое никто
не ломал, и тот, кто её отменил, ждёт ещё и сброса. Сначала спросите задачу.
Делайте так:
try { await ctx.join(hw.open); await ctx.join(() => hw.setZoom(zoom));} on Object catch (error) { ctx.check(); await hw.reset(); ctx.emit(Broken(error)); rethrow;}ctx.check() бросает Cancelled задачи, если задача его приняла, какую бы
ошибку ни поймал catch. Секции on Cancelled, которая спрашивает саму
ошибку, мало: вызов, остановленный через токен, бросает свою ошибку. catch,
который только публикует состояние сбоя, ctx.emit(Broken(error)), тоже ничего
не даёт: на отменённой задаче emit снова бросает Cancelled, а конечное
состояние сбоя — дело параметра ifFailed у enqueue. Подробнее в разделе
«Перехват ошибок внутри тела» страницы
об ошибках.
Шаги детьми
Заголовок раздела «Шаги детьми»Контроллер выполняет по одной корневой задаче за раз, а метод контроллера
ставит в очередь ещё одну. В примерах — две пары методов контроллера
со страницы о детях. _sync(item) и _recordPath(path) создают задачу через
job(...) и ничего не запускают. sync(item) и recordPath(path) — публичные
методы, которые ставят её в очередь: sync(item) возвращает
enqueueJob(_sync(item)).
Так не делайте:
// Неверно: sync ждёт конца этой задачи, а задача ждёт sync.Job<void> syncAndRecord(int item) => enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async { final path = await sync(item).value; await recordPath(path).value; });Текущая задача ждёт новую, а новая ждёт текущую: ничего не движется, пока
close() не отменит обе. Вместо этого выполняйте задачи через ctx.run: они
сразу запускаются детьми текущей задачи, минуя очередь.
Делайте так:
Job<void> syncAndRecord(int item) => enqueue<Ready, void>(key: _Op.syncAndRecord, (ctx) async { final path = await ctx.run(_sync(item)); await ctx.run(_recordPath(path)); });Solo.debug предупреждает о таком ожидании, когда задача встаёт в очередь.
close() или cancelAll(), которых ждут из тела, зависают так же: оба ждут
работающую задачу. Подробнее в разделе «Шаги подряд»
страницы о дочерних задачах и в разделе
«Закрытие из задачи» страницы об отмене.
Ответ за каждую ошибку
Заголовок раздела «Ответ за каждую ошибку»За ошибку, которую никто не прочитал, отвечают: Solo.unansweredHandler, если
он задан, а иначе зона, где задачу создали, и там необработанная ошибка может
завершить приложение. За ошибку, которую задача называет в isExpectedError,
не отвечает никто.
Так не делайте:
// Неверно: сбои всё равно уходят в зону.final class Failures extends SoloObserver { @override void onError( Solo<Object> solo, Job<Object?> job, Object error, StackTrace stackTrace, { required JobErrorAnswer answer, }) => reportCrash(error, stackTrace);}Хук onError наблюдателя или контроллера, обработчик ifFailed и взгляд
на job.outcome видят ошибку, но ни один из них не считается чтением.
Дождитесь job.value и поймайте ошибку, прочитайте job.done либо вызовите
ignoreFailure(), когда результат никому не нужен, а отчёты оставлены хукам,
как наблюдателю выше. Задача, которая знает, какие её ошибки ожидаемы, называет
их в isExpectedError, и ответа за них не просят ни у кого.
Делайте так:
profile.load().ignoreFailure();Сделайте это до микрозадачи после конца задачи: за ошибку, о которой никто
не спросил, отвечают на этой микрозадаче. Подробнее в разделах
«Обработанные и необработанные ошибки»
и «Назвать ожидаемые ошибки» страницы
об ошибках. Чтобы отвечать за все такие ошибки в одном месте, задайте
Solo.unansweredHandler один раз при запуске, как показывает раздел
«Ответ за ошибку».
Двигать часы в fakeAsync
Заголовок раздела «Двигать часы в fakeAsync»Колбэк fakeAsync должен оставаться синхронным.
Так не делайте:
// Неверно: никто не двигает поддельные часы.test('load fills in the name', () async { await fakeAsync((async) async { final profile = ProfileController(FakeProfileApi());
final outcome = await profile.load().done;
expect(outcome, isA<Done<String>>()); await profile.close(); });});await в колбэке приостанавливает его, и двигать поддельные часы больше
некому: тест висит до своего таймаута, а без await перед fakeAsync сразу
кончается зелёным, ничего не выполнив. Двигайте часы через elapse и читайте
исход.
Делайте так:
test('load fills in the name', () { fakeAsync((async) { final profile = ProfileController(FakeProfileApi());
final job = profile.load()..ignoreFailure(); async.elapse(const Duration(milliseconds: 20));
expect(job.outcome, isA<Done<String>>()); expect(profile.currentState, isA<Loaded>());
profile.close(); async.flushTimers(); });});Внутри ничего нельзя дождаться, поэтому ошибку отмечает прочитанной
ignoreFailure(), а close() и последний flushTimers() заканчивают тест
с пустыми часами. Подробнее в разделе
«Ожидание внутри fakeAsync» страницы
о тестировании.
Ключ на каждый запрос
Заголовок раздела «Ключ на каждый запрос»Политика сравнивает ключи, а не аргументы.
Так не делайте:
// Неверно: один ключ на все профили.Job<Profile> load(String id) => enqueue<Ready, Profile>( key: _Op.load, policy: Policy.droppable, (ctx) => ctx.abandonable(() => api.load(id)),);С одним ключом на load(id) политика Policy.droppable принимает
load('grace'), вызванный, пока идёт load('ada'), за дубликат и отдаёт
вызывающему профиль Ады. Включите аргумент в ключ.
Делайте так:
Job<Profile> load(String id) => enqueue<Ready, Profile>( key: (_Op.load, id), policy: Policy.droppable, (ctx) => ctx.abandonable(() => api.load(id)),);Ключом служит любой объект, сравниваемый через ==, так что подойдёт и запись.
Подробнее в разделе «Ключ на каждый запрос»
страницы о задачах.