slide timing: per-speaker timing comments activate speaker view pacing

Port the Google-Docs slide workflow convention: timing comments like
<!-- JD 00:30 --> (speaker code plus MM:SS/HH:MM:SS/seconds duration, or
just <!-- 00:30 --> for single-speaker talks) are converted into
data-timing attributes on the rendered slides' <section> elements, and
the calculated grand total is passed to reveal.js as the totalTime
config value, activating the pacing timer in the speaker view. A
document-supplied totalTime or defaultTiming takes precedence.

A per-speaker time summary is printed during rendering; the new
slide_timing() function computes slide, speaker, and grand totals
directly from an .Rmd source file without rendering it.

Change-Id: I89d37baab06c6ee8be7ab777a70b9b21295c8780
diff --git a/tests/testthat/test-slide_timing.R b/tests/testthat/test-slide_timing.R
new file mode 100644
index 0000000..edcce26
--- /dev/null
+++ b/tests/testthat/test-slide_timing.R
@@ -0,0 +1,190 @@
+test_that("parse_duration_seconds handles all duration formats", {
+  expect_equal(parse_duration_seconds("00:30"), 30)
+  expect_equal(parse_duration_seconds("1:05"), 65)
+  expect_equal(parse_duration_seconds("45"), 45)
+  expect_equal(parse_duration_seconds("1:00:00"), 3600)
+  expect_equal(parse_duration_seconds("1:30:15"), 5415)
+  expect_true(is.na(parse_duration_seconds("abc")))
+})
+
+test_that("extract_timing_comments finds comments", {
+  lines <- c(
+    "---",
+    "title: x",
+    "---",
+    "",
+    "## Slide one",
+    "",
+    "<!-- MK 00:30 -->",
+    "",
+    "## Slide two",
+    "",
+    "<!-- AB 1:00 -->",
+    "<!-- MK 90 -->"
+  )
+  comments <- extract_timing_comments(lines)
+  expect_equal(comments$speaker, c("MK", "AB", "MK"))
+  expect_equal(comments$seconds, c(30, 60, 90))
+  expect_equal(assign_slides(lines, comments, slide_level = 2),
+               c("Slide one", "Slide two", "Slide two"))
+})
+
+test_that("comments without speaker codes are supported", {
+  lines <- c("## Slide one", "<!-- 00:30 -->", "## Slide two", "<!-- 1:05 -->")
+  comments <- extract_timing_comments(lines)
+  expect_true(all(is.na(comments$speaker)))
+  expect_equal(comments$seconds, c(30, 65))
+
+  # a plain number must not be split into a speaker code plus duration
+  comments2 <- extract_timing_comments("<!-- 45 -->")
+  expect_true(is.na(comments2$speaker))
+  expect_equal(comments2$seconds, 45)
+
+  result <- apply_timing_attributes(c(
+    "<section id='s1' class='level2'>",
+    "<!-- 00:30 -->",
+    "</section>"
+  ))
+  expect_match(result[1], "data-timing=\"30\"")
+
+  report <- timing_report_message(extract_timing_comments(lines))
+  expect_equal(report, "Slide timing -- total 1:35")
+})
+
+test_that("comments before first heading belong to title slide", {
+  lines <- c("<!-- MK 00:10 -->", "", "# Title", "", "## Slide")
+  comments <- extract_timing_comments(lines)
+  expect_equal(assign_slides(lines, comments, slide_level = 2), "(title)")
+})
+
+test_that("apply_timing_attributes adds data-timing to sections", {
+  lines <- c(
+    "<html>",
+    "<section id='title-slide'>",
+    "title",
+    "</section>",
+    "<section id='s1' class='level2'>",
+    "<!-- MK 00:30 -->",
+    "</section>",
+    "<section id='s2' class='level2'>",
+    "<!-- MK 0:20 -->",
+    "<!-- AB 00:30 -->",
+    "</section>"
+  )
+  result <- apply_timing_attributes(lines)
+  html <- paste(result, collapse = "\n")
+  expect_match(result[2], "<section id='title-slide'>")
+  expect_match(result[5], "data-timing=\"30\"")
+  # multiple speakers on one slide are summed
+  expect_match(result[8], "data-timing=\"50\"")
+  comments <- attr(result, "comments")
+  expect_equal(sum(comments$seconds), 80)
+})
+
+test_that("apply_timing_attributes is a no-op without comments", {
+  lines <- c("<section id='s1'>", "x", "</section>")
+  result <- apply_timing_attributes(lines)
+  expect_identical(as.vector(result), lines)
+  expect_equal(nrow(attr(result, "comments")), 0)
+})
+
+test_that("inject_total_time activates the pacing timer", {
+  lines <- c(
+    "var opts = {",
+    "  controls: true,",
+    "};",
+    "Reveal.initialize({",
+    "  controls: true,",
+    "  slideNumber: true",
+    "});"
+  )
+  result <- inject_total_time(lines, 90)
+  expect_contains(result, "\t\t\ttotalTime: 90,")
+
+  # existing pacing config is respected
+  with_default <- c(lines, "", "defaultTiming: 60")
+  expect_identical(inject_total_time(with_default, 90), with_default)
+  with_total <- c("totalTime: 120", lines)
+  expect_identical(inject_total_time(with_total, 90), with_total)
+
+  # nothing to hook into -- leave the document alone
+  expect_identical(inject_total_time(c("<html>", "</html>"), 90),
+                   c("<html>", "</html>"))
+})
+
+test_that("rendered presentations carry data-timing attributes", {
+  skip_if_not_pandoc()
+  skip_if_not_installed("xml2")
+  rmd <- local_temp_rmd_file(
+    "---",
+    "title: Timing test",
+    "output: revealjs.ids::revealjs_presentation",
+    "---",
+    "",
+    "## Slide A",
+    "",
+    "Content A",
+    "",
+    "<!-- MK 00:30 -->",
+    "",
+    "## Slide B",
+    "",
+    "Content B",
+    "",
+    "<!-- MK 01:00 -->",
+    "<!-- AB 0:30 -->"
+  )
+  html <- .render_and_read(rmd)
+  timings <- xml2::xml_attr(
+    xml2::xml_find_all(html, "//section[@data-timing]"),
+    "data-timing"
+  )
+  expect_equal(timings, c("30", "90"))
+  # the grand total activates the pacing timer of the notes plugin
+  expect_true(any(grepl("totalTime:\\s*120", html)))
+})
+
+test_that("a user-supplied totalTime is respected", {
+  skip_if_not_pandoc()
+  rmd <- local_temp_rmd_file(
+    "---",
+    "title: Timing test",
+    "output: revealjs.ids::revealjs_presentation",
+    "---",
+    "",
+    "## Slide A",
+    "",
+    "<!-- 00:30 -->"
+  )
+  html <- .render_and_read(rmd, output_options = list(
+    reveal_options = list(totalTime = 1800)
+  ))
+  # passed through to the config...
+  expect_true(any(grepl("totalTime:\\s*1800", html)))
+  # ... and not overwritten by the calculated total
+  expect_false(any(grepl("totalTime:\\s*30", html)))
+})
+
+test_that("slide_timing reports totals", {
+  rmd_file <- tempfile(fileext = ".Rmd")
+  writeLines(c(
+    "---",
+    "title: x",
+    "output: revealjs.ids::revealjs_presentation",
+    "---",
+    "",
+    "## Slide one",
+    "",
+    "<!-- MK 00:30 -->",
+    "",
+    "## Slide two",
+    "",
+    "<!-- AB 1:00 -->",
+    "<!-- MK 90 -->"
+  ), rmd_file)
+  expect_output(result <- slide_timing(rmd_file), "total")
+  expect_equal(result$total, 180)
+  expect_equal(result$speakers$seconds[result$speakers$speaker == "MK"], 120)
+  expect_equal(result$speakers$seconds[result$speakers$speaker == "AB"], 60)
+  unlink(rmd_file)
+})