tests/testthat/test-nvenc-front-door.R

# The nvenc availability guard at the front door of every fan-out verb (M57).
#
# A verb that fans out through ffm_batch() -> purrr::pmap() cannot get useful
# blame from threading `call` into its pipeline: the pipeline's caller_env()
# lands on the anonymous closure, so an unavailable nvenc encoder was reported
# as "Error in `purrr::pmap(jobs, .f, ...)`" (LESSONS M47/M48-F1). D035
# licenses re-running the availability check at each such verb's front door.
#
# Availability is simulated with the `tidymedia.hardware_encoders` option seam, so
# these tests need no GPU and no nvenc-capable FFmpeg build.

# --- AC2: one abort site -----------------------------------------------------
#
# The front door and the pipeline must reach the same worded abort. Comparing
# the two functions directly is the sharpest form of that check: a copy of the
# wording in resolve_hw_encoder() would pass every verb-level test in this file
# right up until someone edited one of the two.

test_that("the front-door guard and resolve_hw_encoder() word the abort identically", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  front <- tryCatch(
    tidymedia:::check_hardware_available("libx264", "nvenc", FALSE),
    condition = function(cnd) cnd
  )
  pipeline <- tryCatch(
    tidymedia:::resolve_hw_encoder("libx264", "nvenc", FALSE),
    condition = function(cnd) cnd
  )
  expect_s3_class(front, "rlang_error")
  expect_s3_class(pipeline, "rlang_error")
  expect_identical(conditionMessage(front), conditionMessage(pipeline))
  expect_match(conditionMessage(front), "h264_nvenc", fixed = TRUE)
})

# --- the fan-out family -----------------------------------------------------
#
# The nine verbs that take `hardware` AND fan out through ffm_batch(). The list
# is fixed rather than derived, so a verb that gains `hardware` later fails the
# completeness test below instead of silently dropping out of the sweep.
nvenc_fanout_verbs <- function() {
  c("segment_video", "anonymize_video_batch", "segment_video_batch",
    "standardize_video_batch", "crop_video_batch", "format_for_web_batch",
    "separate_audio_video_batch", "compare_videos_batch",
    "picture_in_picture_batch")
}

# The seven that take `hardware` and call their pipelines DIRECTLY, where `call`
# already resolves to the verb, so M57 adds them no guard (M47 F8). Named here
# only so the two lists together can be checked against the package's exports:
# a new hardware-bearing verb has to be classified, not quietly omitted.
nvenc_direct_verbs <- function() {
  c("separate_audio_video", "crop_video", "format_for_web", "standardize_video",
    "anonymize_video", "compare_videos", "picture_in_picture")
}

# Call templates. Eight come from helper-codec-family.R, which already models
# each verb's real column names -- picture_in_picture_batch takes main/overlay
# columns (D015) and compare_videos_batch an `inputs` list-column, and getting
# either wrong records a schema error in place of the abort under test (M54).
# format_for_web_batch has no codec argument at all (its recipe fixes both
# codecs by identity), so it is not in that helper and gets a template here.
nvenc_fanout_call <- function(verb, input, out) {
  if (identical(verb, "format_for_web_batch")) {
    return(list(jobs = tibble::tibble(input = input, output = out)))
  }
  args <- codec_family_call(verb, input, out)
  # separate_audio_video*'s video_codec defaults to "copy", which codec_family()
  # refuses outright -- a DIFFERENT abort, and one that would satisfy a blame
  # assertion while measuring nothing about availability (M54). Name an
  # h264-family codec so the cell measures what it claims to.
  if (identical(verb, "separate_audio_video_batch")) {
    args$video_codec <- "libx264"
  }
  args
}

# Which function the condition blames, rendered from the call's head alone: the
# full call deparses a whole jobs tibble inline.
nvenc_blamed <- function(cnd) {
  cl <- conditionCall(cnd)
  if (is.null(cl)) return(NA_character_)
  paste(deparse(cl[[1]]), collapse = "")
}

nvenc_fanout_catch <- function(verb, input, out = "out.mp4", parallel = FALSE,
                               extra = list()) {
  f <- get(verb, envir = asNamespace("tidymedia"))
  args <- nvenc_fanout_call(verb, input, out)
  args$hardware <- "nvenc"
  args$fallback <- FALSE
  args$run <- FALSE
  if ("parallel" %in% names(formals(f))) args$parallel <- parallel
  # Plain replacement, never modifyList(): a tibble IS a list, so modifyList
  # merges `extra$jobs` column-wise into the template's jobs instead of
  # replacing it, and it DELETES any element whose value is NULL rather than
  # setting it -- which would silently drop a `video_codec = NULL` case.
  for (nm in names(extra)) args[nm] <- list(extra[[nm]])
  # Catch the ERROR, never any condition: at parallel = TRUE the sequential-plan
  # warning arrives first and a `condition =` handler would return that instead
  # (measured while recording the master readings).
  withCallingHandlers(
    tryCatch({
      do.call(verb, args, envir = asNamespace("tidymedia"))
      NULL
    }, error = function(e) e),
    warning = function(w) invokeRestart("muffleWarning")
  )
}

test_that("the two lists together are every hardware-bearing exported verb", {
  exported <- getNamespaceExports("tidymedia")
  hw <- Filter(function(nm) {
    # `inherits = TRUE` (the default): a re-export such as `.data` is listed in
    # NAMESPACE but bound in the imports env, and an inherits = FALSE lookup
    # errors on it outright (M55).
    obj <- get(nm, envir = asNamespace("tidymedia"))
    is.function(obj) && "hardware" %in% names(formals(obj))
  }, exported)
  # The two capability helpers carry `hardware` but are not verbs and have no
  # front door to mirror; nvenc_hardware_exports() excludes them for the same
  # reason.
  expect_setequal(setdiff(hw, nvenc_hardware_helpers()),
                  c(nvenc_fanout_verbs(), nvenc_direct_verbs()))
})

# --- AC1: the abort names the verb ------------------------------------------

test_that("an unavailable nvenc encoder blames the fan-out verb, not purrr::pmap()", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  for (verb in nvenc_fanout_verbs()) {
    cnd <- nvenc_fanout_catch(verb, input)
    expect_s3_class(cnd, "rlang_error")
    # Match the MESSAGE first: a jobs-schema error blames the same front door
    # and would satisfy a bare conditionCall() assertion with the guard
    # deleted -- three M54 review rounds were paid for exactly that (M54).
    expect_match(conditionMessage(cnd), "nvenc encoder .* is not available",
                 info = verb)
    expect_identical(nvenc_blamed(cnd), verb, info = verb)
  }
})

test_that("the abort names the verb at parallel = TRUE too", {
  skip_if_not_installed("furrr")
  withr::local_options(tidymedia.hardware_encoders = character(0))
  old <- future::plan(future::sequential)
  withr::defer(future::plan(old))
  input <- make_input()
  cnd <- nvenc_fanout_catch("standardize_video_batch", input, parallel = TRUE)
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "nvenc encoder .* is not available")
  expect_identical(nvenc_blamed(cnd), "standardize_video_batch")
})

# --- AC3: the guard sweeps the whole video_codec column ---------------------
#
# A _batch call is one `hardware` choice over many rows, but the FAMILY is
# per row: seven of the eight _batch verbs let a `video_codec` column override
# the argument, and h264_nvenc being listed says nothing about av1_nvenc. A
# guard reading only the argument would pass a table whose second row cannot
# encode.

test_that("the guard checks every family a video_codec column spells", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  input <- make_input()
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         video_codec = c("libx264", "libaom-av1"))
  cnd <- nvenc_fanout_catch("standardize_video_batch", input,
                            extra = list(jobs = jobs))
  expect_s3_class(cnd, "rlang_error")
  # The AV1 row is the one with no encoder; naming h264_nvenc here would mean
  # the guard had read the argument and stopped.
  expect_match(conditionMessage(cnd), "av1_nvenc", fixed = TRUE)
  expect_identical(nvenc_blamed(cnd), "standardize_video_batch")
})

test_that("the same column compiles when every family is available", {
  withr::local_options(tidymedia.hardware_encoders = c("h264_nvenc", "av1_nvenc"))
  input <- make_input()
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         video_codec = c("libx264", "libaom-av1"))
  expect_null(nvenc_fanout_catch("standardize_video_batch", input,
                                 extra = list(jobs = jobs)))
})

test_that("an NA cell and an absent column both read as the h264 family", {
  input <- make_input()
  # NA is the column form of the NULL sentinel (D022), which
  # resolve_hw_encoder() resolves to h264 -- so a seam holding only av1_nvenc
  # must refuse both shapes, naming h264_nvenc.
  withr::local_options(tidymedia.hardware_encoders = "av1_nvenc")
  cnd <- nvenc_fanout_catch("segment_video_batch", input,
                            extra = list(jobs = tibble::tibble(
                              input = input, output = "a.mp4",
                              start = 0, end = 1, video_codec = NA)))
  expect_match(conditionMessage(cnd), "h264_nvenc", fixed = TRUE)
  expect_identical(nvenc_blamed(cnd), "segment_video_batch")

  # And with no column at all, the argument's own NULL default.
  bare <- nvenc_fanout_catch("segment_video_batch", input)
  expect_match(conditionMessage(bare), "h264_nvenc", fixed = TRUE)
  expect_identical(nvenc_blamed(bare), "segment_video_batch")
})

test_that("format_for_web_batch checks h264, the codec its recipe fixes", {
  withr::local_options(tidymedia.hardware_encoders = "av1_nvenc")
  input <- make_input()
  cnd <- nvenc_fanout_catch("format_for_web_batch", input)
  expect_match(conditionMessage(cnd), "h264_nvenc", fixed = TRUE)
  expect_identical(nvenc_blamed(cnd), "format_for_web_batch")
})

# --- AC4: fallback = TRUE reaches no AVAILABILITY guard ---------------------
#
# M100 wrote this section as "reaches no front-door guard" and pinned the
# unmappable-pair case to the fan-out. M107 narrowed it: `fallback` answers a
# question about the MACHINE -- this build does not list the encoder -- and that
# is the guard the early return still skips. It answers nothing about a pair the
# table never held, so that refusal moved to the front door on both arms (D085).

test_that("fallback = TRUE still falls back, once per row", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"))
  n <- 0
  out <- withCallingHandlers(
    standardize_video_batch(jobs, hardware = "nvenc", fallback = TRUE,
                            run = FALSE),
    message = function(m) {
      if (grepl("falling back", conditionMessage(m))) n <<- n + 1
      invokeRestart("muffleMessage")
    }
  )
  # Two, not one: the message belongs to the per-row resolution, and a guard
  # that hoisted or suppressed it would show up here as 1 or 0. Measured at 2
  # on master for the same table, so this is a count carried across, not a
  # count invented here.
  expect_identical(n, 2)
  expect_s3_class(out, "data.frame")
})

test_that("fallback = TRUE refuses an out-of-table pair at the verb (M107)", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  # A prores codec under nvenc: the (family, backend) pair is not in the table,
  # and no build of FFmpeg will add it, so `fallback` has nothing to offer and
  # the front-door column sweep refuses the call on either arm. M100 pinned this
  # to the fan-out (`purrr::pmap`), which is the frame this front-door call
  # exists to keep out of the caller's error -- that pin is what M107 reverses.
  # The codec rides in as a jobs COLUMN, so the sweep's list form is what runs.
  jobs <- tibble::tibble(input = input, output = "a.mp4",
                         video_codec = "prores")
  cnd <- nvenc_fanout_catch("standardize_video_batch", input,
                            extra = list(jobs = jobs, fallback = TRUE))
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), 'nvenc has no "prores" encoder',
               fixed = TRUE)
  expect_identical(nvenc_blamed(cnd), "standardize_video_batch")
})

test_that("fallback = TRUE still leaves the availability guard to the fan-out", {
  # The other half, and the reason the early return is still there: an encoder
  # the table HOLDS and this build does not list is exactly what `fallback` is
  # for, so the front door must let it through to the per-row resolution rather
  # than abort. Same verb and the same empty build as the test above, with the
  # only difference being a codec the table holds.
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  jobs <- tibble::tibble(input = input, output = "a.mp4",
                         video_codec = "libx264")
  msgs <- character()
  out <- withCallingHandlers(
    standardize_video_batch(jobs, hardware = "nvenc", fallback = TRUE,
                            run = FALSE),
    message = function(m) {
      msgs <<- c(msgs, cli::ansi_strip(conditionMessage(m)))
      invokeRestart("muffleMessage")
    }
  )
  expect_s3_class(out, "data.frame")
  expect_true(any(grepl("falling back", msgs, fixed = TRUE)))
})

# --- the preconditions each front door mirrors ------------------------------
#
# Two pipelines abort BEFORE they ever reach resolve_hw_encoder(), so on those
# verbs the front-door guard must not fire: doing so would replace a specific
# message with an availability one that is not why the call failed (D035's
# second condition). These tests are what catches the guard's gate being
# dropped -- without them, widening it reads as green everywhere else.

test_that("a non-re-encoding cut still reports the cut, not availability", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  for (verb in c("segment_video", "segment_video_batch")) {
    cnd <- nvenc_fanout_catch(verb, input,
                              extra = list(reencode = FALSE, video_codec = NULL))
    expect_s3_class(cnd, "rlang_error")
    expect_match(conditionMessage(cnd), "need a re-encoding cut", info = verb)
    expect_no_match(conditionMessage(cnd), "is not available", info = verb)
  }
})

test_that("an all-FALSE reencode column reaches no front-door guard", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  # The column form of the scalar case above: every row copies, so no row has an
  # encoder to check and segment_pipeline()'s own cut error must survive.
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         start = c(0, 0), end = c(1, 1),
                         reencode = c(FALSE, FALSE))
  cnd <- nvenc_fanout_catch("segment_video_batch", input,
                            extra = list(jobs = jobs))
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "need a re-encoding cut")
  expect_no_match(conditionMessage(cnd), "is not available")
})

test_that("a MIXED reencode column is refused at the front door", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  # `reencode` is per row, and M57's all-or-nothing gate skipped the availability
  # guard entirely here, sending the re-encoding row back to blaming
  # purrr::pmap() (M57 review F4). Both guards are row-swept now, and since M58
  # the CUT contradiction reports: row 2 copies while `hardware = "nvenc"` names
  # an encoder, which is a contradiction whatever this machine has installed.
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         start = c(0, 0), end = c(1, 1),
                         reencode = c(TRUE, FALSE))
  cnd <- nvenc_fanout_catch("segment_video_batch", input,
                            extra = list(jobs = jobs))
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "need a re-encoding cut")
  expect_identical(nvenc_blamed(cnd), "segment_video_batch")
})

test_that("a copying row's own error reports rather than its family", {
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  input <- make_input()
  # The AV1 row copies, so it names no encoder and the h264-only seam must not
  # refuse the table: a guard reading the whole column would abort on av1_nvenc.
  # Its own cut error is what this call fails on instead -- since M58 at the
  # front door rather than inside the fan-out, which is why the blame assertion
  # below is new.
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         start = c(0, 0), end = c(1, 1),
                         reencode = c(TRUE, FALSE),
                         video_codec = c("libx264", "libaom-av1"))
  cnd <- nvenc_fanout_catch("segment_video_batch", input,
                            extra = list(jobs = jobs))
  expect_s3_class(cnd, "rlang_error")
  expect_no_match(conditionMessage(cnd), "av1_nvenc")
  expect_match(conditionMessage(cnd), "need a re-encoding cut")
  expect_identical(nvenc_blamed(cnd), "segment_video_batch")
})

# The precedence M58 reassigns, replacing the pair M57 pinned the other way up.
# A mixed-`reencode` call fails either way; what changes is that it now fails
# the SAME way on every machine, because a contradiction between two arguments
# is decided without consulting the local FFmpeg build while availability is
# not (the M54 lesson).

test_that("on a mixed column the cut contradiction reports before availability", {
  input <- make_input()
  jobs <- tibble::tibble(input = c(input, input), output = c("a.mp4", "b.mp4"),
                         start = c(0, 0), end = c(1, 1),
                         reencode = c(TRUE, FALSE))
  # Encoder present: row 2's cut error, at the verb -- where M57 left it inside
  # the fan-out, blaming purrr::pmap().
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  present <- nvenc_fanout_catch("segment_video_batch", input,
                                extra = list(jobs = jobs))
  expect_match(conditionMessage(present), "need a re-encoding cut")
  expect_identical(nvenc_blamed(present), "segment_video_batch")
  # Encoder absent: the SAME error, not the availability one M57 raised here.
  # The set of failing calls is unchanged; what moves is which error reports.
  withr::local_options(tidymedia.hardware_encoders = character(0))
  absent <- nvenc_fanout_catch("segment_video_batch", input,
                               extra = list(jobs = jobs))
  expect_match(conditionMessage(absent), "need a re-encoding cut")
  expect_no_match(conditionMessage(absent), "is not available")
  expect_identical(nvenc_blamed(absent), "segment_video_batch")
  expect_identical(conditionMessage(present), conditionMessage(absent))
})

test_that("a copy video_codec still reports the copy, not availability", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  cnd <- nvenc_fanout_catch("separate_audio_video_batch", input,
                            extra = list(video_codec = "copy"))
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "needs a re-encoding")
  expect_no_match(conditionMessage(cnd), "is not available")
})

test_that("a within-row output collision still reports the collision", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  # separate_audio_video_batch reshapes N rows -> 2N before pooling the duplicate
  # check, so audiofile == videofile is a collision only the reshaped table can
  # see (M26). Above the reshape the availability guard preempted it and this
  # caller was told the encoder was missing (review F3); below it, the guard is
  # last in the front-door block on this verb as on the other seven.
  jobs <- tibble::tibble(input = input, audiofile = "same.mkv",
                         videofile = "same.mkv")
  cnd <- nvenc_fanout_catch("separate_audio_video_batch", input,
                            extra = list(jobs = jobs))
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "same output path")
  expect_no_match(conditionMessage(cnd), "is not available")
  expect_identical(nvenc_blamed(cnd), "separate_audio_video_batch")
})

test_that("a mixed copy column reports the copy conflict before availability", {
  input <- make_input()
  # The copy cell has no encoder to check and the libx264 cell does, so both
  # errors are live on this table (M57 review F2). M57 let the seam decide which
  # one reported; M58 fixes it on the copy conflict, so the same wrong call is
  # diagnosed identically on a machine with the encoder and one without.
  jobs <- tibble::tibble(input = c(input, input),
                         audiofile = c("a1.aac", "a2.aac"),
                         videofile = c("v1.mp4", "v2.mp4"),
                         video_codec = c("copy", "libx264"))
  withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
  present <- nvenc_fanout_catch("separate_audio_video_batch", input,
                                extra = list(jobs = jobs))
  expect_match(conditionMessage(present), "needs a re-encoding")
  expect_identical(nvenc_blamed(present), "separate_audio_video_batch")
  withr::local_options(tidymedia.hardware_encoders = character(0))
  absent <- nvenc_fanout_catch("separate_audio_video_batch", input,
                               extra = list(jobs = jobs))
  expect_match(conditionMessage(absent), "needs a re-encoding")
  expect_no_match(conditionMessage(absent), "is not available")
  expect_identical(nvenc_blamed(absent), "separate_audio_video_batch")
  expect_identical(conditionMessage(present), conditionMessage(absent))
})

# --- the precedence the guard reassigns -------------------------------------
#
# D035's second condition admits that a front-door guard reports before checks
# that live in the pipeline, and requires that this be tested rather than
# assumed away. AC6's grep enumerated the `cli_abort()` sites inside the nine
# verbs' `*_pipeline()` functions; it does NOT reach an abort a pipeline raises
# through a helper, nor a per-row check living in the batch closure. Those are
# pinned here instead of left unmeasured.

# The three cases this section pinned at M57 have REVERSED, and the pin moved
# with them. M57 measured a regions cell, a crop dimension and an inset margin
# all reporting the availability error first once the encoder was absent, and
# recorded that as a precedence the guard had taken. M59 moved all three checks
# to their verbs' front doors, ahead of check_nvenc_available(), so each now
# reports its own value error on a machine with the encoder and on a machine
# without it alike -- which is what makes the diagnosis machine-independent, the
# property M54 named and D036 ordered for. The new pin, with a control proving
# the availability abort is still live on the same verb, is
# test-value-check-front-door.R's AC5(a) test; asserting it here as well would
# be a second site for one claim.
#
# compare_videos_batch's three-input `resize` case left this sweep at M58 for
# the same reason, one milestone earlier.

# The other three verbs carrying both guards. Their contradictions are decided
# without reference to any encoder, so unlike the two mixed-column cases above
# these need no mixed table: a plain uniform call has both errors live at once.

test_that("a contradiction reports before availability on the fan-in verbs", {
  input <- make_input()
  cases <- list(
    # Condition 4: an audio encoder named with no audio mapped.
    list(verb = "compare_videos_batch", own = "needs an audio stream to encode",
         args = list(jobs = tibble::tibble(inputs = list(c(input, input)),
                                           output = "o.mp4"),
                     audio_codec = "aac")),
    # Condition 6: the same contradiction on the overlay verb.
    list(verb = "picture_in_picture_batch",
         own = "needs an audio stream to encode",
         args = list(jobs = tibble::tibble(main = input, overlay = input,
                                           output = "o.mp4"),
                     audio_codec = "aac")),
    # Condition 5: resize across three inputs. Encoder-independent too, which
    # is why it left the availability-first sweep above at M58.
    list(verb = "compare_videos_batch", own = "exactly two inputs",
         args = list(jobs = tibble::tibble(
           inputs = list(c(input, input, input)), output = "o.mp4"),
           resize = TRUE))
  )
  for (case in cases) {
    call_it <- function() {
      args <- case$args
      args$hardware <- "nvenc"
      args$run <- FALSE
      tryCatch(do.call(case$verb, args, envir = asNamespace("tidymedia")),
               error = function(e) e)
    }
    # Both seams, and the assertion is that they agree: a contradiction between
    # two arguments is settled without consulting the local FFmpeg build, so the
    # same wrong call must be diagnosed identically on any machine (M54).
    withr::local_options(tidymedia.hardware_encoders = "h264_nvenc")
    present <- call_it()
    withr::local_options(tidymedia.hardware_encoders = character(0))
    absent <- call_it()
    expect_match(conditionMessage(present), case$own, info = case$verb)
    expect_match(conditionMessage(absent), case$own, info = case$verb)
    expect_no_match(conditionMessage(absent), "is not available",
                    info = case$verb)
    expect_identical(conditionMessage(present), conditionMessage(absent))
    expect_identical(nvenc_blamed(absent), case$verb, info = case$verb)
  }
})

# --- the guard validates what it reads --------------------------------------

test_that("a malformed fallback reports its own type error, on any machine", {
  input <- make_input()
  jobs <- tibble::tibble(input = input, output = "a.mp4")
  # The guard used to test isTRUE(fallback), under which NA read as FALSE and
  # the call was told the encoder was missing -- but only where it WAS missing,
  # so one wrong call had two diagnoses depending on the machine (review F1).
  # Both seams must now give the type error resolve_hw_encoder() would give.
  for (seam in list("h264_nvenc", character(0))) {
    withr::local_options(tidymedia.hardware_encoders = seam)
    cnd <- tryCatch(
      standardize_video_batch(jobs, hardware = "nvenc", fallback = NA,
                              run = FALSE),
      error = function(e) e
    )
    expect_s3_class(cnd, "rlang_error")
    expect_match(conditionMessage(cnd), "must be `TRUE` or `FALSE`")
    expect_no_match(conditionMessage(cnd), "is not available")
    expect_identical(nvenc_blamed(cnd), "standardize_video_batch")
  }
})

test_that("hardware = \"none\" reaches no fallback check at the front door", {
  withr::local_options(tidymedia.hardware_encoders = character(0))
  input <- make_input()
  jobs <- tibble::tibble(input = input, output = "a.mp4")
  # The validation sits after the hardware test on purpose: a hardware = "none"
  # call never consults `fallback` at the front door, so refusing one there
  # would be a refusal master does not make. It still fails, from the pipeline.
  cnd <- tryCatch(
    standardize_video_batch(jobs, hardware = "none", fallback = NA,
                            run = FALSE),
    error = function(e) e
  )
  expect_s3_class(cnd, "rlang_error")
  expect_match(conditionMessage(cnd), "must be `TRUE` or `FALSE`")
  expect_false(identical(nvenc_blamed(cnd), "standardize_video_batch"))
})

test_that("resolve_hw_encoder() reaches the abort by calling the shared guard", {
  # Read the function object, never the source tree: a test that opens R/ under
  # the package root SKIPS under R CMD check, which runs against an installed
  # package with no R/ dir, and so looks healthy while never running (M51).
  # The body lives in resolve_hw_encoder_info() since M135; the exported-shape
  # wrapper only unwraps its answer.
  src <- deparse(body(tidymedia:::resolve_hw_encoder_info))
  expect_true(any(grepl("check_hardware_available", src, fixed = TRUE)))
  # cli_inform() stays -- that is the fallback message, not the abort.
  expect_false(any(grepl("cli_abort", src, fixed = TRUE)))
})

Try the tidymedia package in your browser

Any scripts or data that you put into this service are public.

tidymedia documentation built on Oct. 11, 2026, 5:08 p.m.