From 840ab86de8e089d407a117788d8f74bda23aa5bb Mon Sep 17 00:00:00 2001 From: Sean Thimons Date: Fri, 30 Oct 2026 14:47:05 +0000 Subject: [PATCH 1/2] docs(app): add Shiny app guide - Document app installation, launch, analysis, and pooling workflows - Link the guide from the README --- README.md | 33 ++++++- vignettes/articles/shiny-app.Rmd | 142 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 vignettes/articles/shiny-app.Rmd diff --git a/README.md b/README.md index ee4455c..b5b20b7 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,35 @@ The package provides: ## Shiny app -A work-in-progress Shiny interface lives in the -[`app/`](https://github.com/seanthimons/singlehit/tree/main/app) directory. It is -a separate package that depends on `singlehit`; installing `singlehit` does not -install or require it. The app is licensed under GPL-3, while `singlehit` +If you would rather not write R code, the `singlehitapp` Shiny app runs the same +analysis in your web browser. It is a separate package stored in the +[`app/`](https://github.com/seanthimons/singlehit/tree/main/app) directory of +this repository. Installing `singlehit` does not install the app, so install +both: + +```r +install.packages("pak") # only needed once +pak::pak("seanthimons/singlehit") # the package (the app needs 0.3.0 or later) +pak::pak("seanthimons/singlehit/app") # the app +singlehitapp::run_app() # opens the app in your browser +``` + +The tabs, from left to right: + +- **Data:** load a CSV/TSV/TXT file or the Ward rotavirus example data. A QMRA + wiki `.xlsx` workbook, or the bundled sample, goes to the Library tab instead. +- **Library:** filter and plot QMRA wiki experiments, send one to Fit, or choose + several to test for pooling. +- **Pooling:** test whether the chosen experiments can share one curve, and send + the pooled data to Fit. +- **Fit:** the dose-trend test, parameter estimates, and goodness of fit. +- **Compare:** the recommended model and the full assessment. +- **Bootstrap:** confidence intervals, computed in the background. +- **Plots:** the fitted curves, with PNG download. +- **Explore:** build your own plots by dragging variables. + +The [app guide](https://seanthimons.github.io/singlehit/articles/shiny-app.html) +walks through each tab. The app is licensed under GPL-3, while `singlehit` remains MIT. ## Prerequisites diff --git a/vignettes/articles/shiny-app.Rmd b/vignettes/articles/shiny-app.Rmd new file mode 100644 index 0000000..f015593 --- /dev/null +++ b/vignettes/articles/shiny-app.Rmd @@ -0,0 +1,142 @@ +--- +title: "Using the Shiny app" +--- + +The `singlehitapp` package lets you run a singlehit analysis in your web +browser without writing R code. You load data, fit the models, compare them, +add bootstrap confidence intervals, and download plots. You can also browse +the QMRA wiki experiment library and test whether experiments can be pooled. +Everything the app does calls exported singlehit functions, so its results +match the package's results. + +## Install and launch + +The app is a separate package stored in the `app/` directory of the singlehit +repository. Run these lines once at the R console: + +```r +install.packages("pak") +pak::pak("seanthimons/singlehit") # singlehit 0.3.0 or later +pak::pak("seanthimons/singlehit/app") # the app +``` + +Then start it whenever you want to use it: + +```r +singlehitapp::run_app() +``` + +The app opens in your browser. While it runs, the R console is busy. To stop +the app, close the browser tab and press Esc (or Ctrl+C) in the console. + +If you are working from a clone of the repository, run the app against the +source code instead: + +```r +setwd("app") +source("dev/run_dev.R") +``` + +## Analyse one dataset + +The tabs run from left to right. For a single dataset you use Data, then Fit, +Compare, Bootstrap, Plots, and Explore. + +1. **Data.** Upload a `.csv`, `.tsv`, or `.txt` file with one row per dose + group, or press **Use Ward rotavirus example data**. The file needs the + columns described in [Input data format](../index.html#input-data-format). + If the app does not recognise the column names, choose the dose, positive, + and negative columns from the menus. The table shows the data that the + other tabs will analyse. +2. **Fit.** Choose at least two models (exponential, beta-Poisson, and the + slower exact beta-Poisson) and the significance level, then press + **Run analysis**. The tab shows the dose-trend test, the parameter + estimates, and the goodness of fit. This step does not bootstrap. +3. **Compare.** Read the primary recommendation first. It combines each + model's absolute fit with the chi-squared deviance comparison. The + assessment table below gives the details. The consensus vote is marked + *Experimental*, so do not use it as the main result. +4. **Bootstrap.** Set the number of replicates (default 1,000) and an + optional random seed, then press **Run bootstrap**. The exact beta-Poisson + model has its own, smaller replicate count because it is much slower. The + bootstrap runs in the background, so you can use other tabs while it works. + Each model has a progress bar, and **Cancel** stops the run. When it + finishes, the tab shows percentile intervals for the parameters and + effective doses, and confidence bands appear on the Plots and Explore tabs. + Use the same seed to get the same intervals again. +5. **Plots.** Show all fitted models in one panel (the overlay), one panel per + model, or a single model. Confidence bands appear in the per-model and + single-model views after a bootstrap; the overlay never shows bands. Set the + width, height, and resolution, then download the plot as a PNG. +6. **Explore.** Build your own plot with + [esquisse](https://dreamrs.github.io/esquisse/) by dragging variables onto + the axes, colour, and other settings. Under **Data to explore**, choose the + observed dose groups, the fitted curves, or the residuals. After a + bootstrap, **Draw confidence bands** creates a ribbon plot of the fitted + curves on a log dose axis. To hover and zoom, turn on "Use {plotly} to + render plot" under Options. + +## Browse the QMRA wiki library and test pooling + +Pooling has its own path through the tabs: Data, Library, Pooling, and then +Fit. + +1. **Data.** Press **Use QMRA wiki sample workbook**, or upload a QMRA wiki + `.xlsx` workbook with a "raw data" sheet. The bundled sample holds 18 + experiments; the full workbook is not distributed with the app. A workbook + opens on the Library tab and leaves the current dataset unchanged. +2. **Library.** Filter experiments by pathogen group, pathogen, outcome, dose + unit, host, exposure route, strain or isolate, and preferred model. The plot + draws each experiment's preferred model on a log dose axis. You can split it + into panels (facets) and colour it by any filter field except pathogen. Dose units + differ between studies, so compare curves only within one unit. Curves + reported by the wiki only as parameters are drawn dashed. + - To analyse one experiment, choose it and press **Send to Data and Fit**. + Experiments that singlehit cannot fit, such as those with fewer than + three doses, say why instead. + - To test pooling, choose two or more experiments under **Pooling set** and + press **Test poolability**. +3. **Pooling.** Before the test will run, you must answer **Why do these + experiments belong together?** For example: same pathogen, host, route, and + outcome. Decide this before you look at any results. Then press + **Run poolability test**. The app blocks the test for data problems, such as + mixed or missing dose units, an experiment the wiki has already pooled, or + duplicate counts. Differences in pathogen, host, route, outcome, or strain + produce only a warning. The results show the likelihood-ratio test, each + experiment's own fit against the pooled fit, and the wiki's own pooled + entry if one exists. A test that does not reject pooling is not evidence + that the experiments are equivalent. +4. **Send pooled data to Data and Fit** makes the tested set the current + dataset, so you can run Fit, Compare, Bootstrap, and Plots on it. Counts at + doses that the experiments share are added together. On the Plots tab you + can colour the observed points by experiment, strain, host, route, or + outcome. + +Every pooling run is recorded in a session log with the rationale, any +warnings, and the p-values. Download it as CSV for your records. The log +counts how many tests you have run, because none of the p-values are adjusted +for multiple testing. + +The Pooling tab also has an **exploratory** section. It runs every pairwise +test as a heatmap and searches for groups of experiments. Treat what it finds +as a hypothesis, not a result. A pooling set chosen from it, or contained in a +set you have already explored, is marked *data-informed* because its test is +biased toward pooling. The +[pooling article](https://seanthimons.github.io/singlehit/articles/pooling.html) +explains why. + +Neither the checks nor the test can detect two entries that report the same +subjects. For example, experiments 108 and 139 in the sample have identical +counts at six of their eight doses. Pooling them would count the same +volunteers twice. + +## Reporting problems + +Report bugs and confusing behaviour on the +[issue tracker](https://github.com/seanthimons/singlehit/issues). Include: + +- the tab you were on; +- what you clicked or entered; +- what you expected to happen and what happened instead; +- any error message; and +- your data file, if you can share it. From 69967a021329485975c1834bfce321cbca89b362 Mon Sep 17 00:00:00 2001 From: Sean Thimons Date: Fri, 30 Oct 2026 15:14:47 +0000 Subject: [PATCH 2/2] feat(release): sync app releases with singlehit - Pin the app package and its singlehit dependency to each release - Group app changes in NEWS under a Shiny app section --- .github/scripts/filter-app-news.R | 85 ++++++++++++------ .github/scripts/sync-app-release.R | 58 ++++++++++++ .github/workflows/README.md | 26 ++++-- .github/workflows/release.yaml | 2 + README.md | 15 ++-- app/README.md | 20 +++-- tests/testthat/test-app-release-sync.R | 48 ++++++++++ tests/testthat/test-news-filter.R | 118 ++++++++++++++++++++----- vignettes/articles/shiny-app.Rmd | 11 ++- 9 files changed, 314 insertions(+), 69 deletions(-) create mode 100644 .github/scripts/sync-app-release.R create mode 100644 tests/testthat/test-app-release-sync.R diff --git a/.github/scripts/filter-app-news.R b/.github/scripts/filter-app-news.R index 8968adb..f436aa9 100644 --- a/.github/scripts/filter-app-news.R +++ b/.github/scripts/filter-app-news.R @@ -1,11 +1,13 @@ -# Drop entries for app-only commits from NEWS.md. +# Move entries for app-only commits in NEWS.md into a "Shiny app" section. # # The release workflow runs this after autonewsmd writes NEWS.md. autonewsmd # lists every conventional commit, but the Shiny app in app/ is a separate -# package with its own audience, so its changes do not belong in the singlehit -# release notes. An entry is dropped when its commit has the conventional scope -# `app` (for example `feat(app): ...`) or changed only files under app/. Entries -# whose commit cannot be inspected are kept. +# package released in step with singlehit (see sync-app-release.R), so its +# changes would otherwise read as singlehit features. Each version's app +# entries are gathered under "#### Shiny app" at the end of that version. An +# entry counts as the app's when its commit has the conventional scope `app` +# (for example `feat(app): ...`) or changed only files under app/. Entries whose +# commit cannot be inspected stay where they are. news_entry_sha <- function(entry) { match <- regmatches(entry, regexpr("/tree/[0-9a-f]{40}", entry)) @@ -32,26 +34,9 @@ is_app_commit <- function(commit) { scoped || only_app } -# `inspect` maps a commit sha to its subject and files; a parameter so the -# filter can be tested without a repository. -filter_app_news <- function(lines, inspect = inspect_commit) { - keep <- rep(TRUE, length(lines)) - i <- 1L - while (i <= length(lines)) { - if (startsWith(lines[[i]], "- ")) { - end <- i - while (end < length(lines) && startsWith(lines[[end + 1L]], " ")) end <- end + 1L - sha <- news_entry_sha(paste(lines[i:end], collapse = "\n")) - if (!is.na(sha) && is_app_commit(inspect(sha))) keep[i:end] <- FALSE - i <- end + 1L - } else { - i <- i + 1L - } - } - lines <- lines[keep] - - # Remove a "#### Section" heading left with no entries, with the blank line - # that follows it. +# Remove each "#### Section" heading left with no entries, with the blank line +# that follows it. +drop_empty_sections <- function(lines) { is_heading <- grepl("^#{2,4} ", lines) drop <- logical(length(lines)) for (k in which(startsWith(lines, "#### "))) { @@ -69,10 +54,56 @@ filter_app_news <- function(lines, inspect = inspect_commit) { lines[!drop] } +# `inspect` maps a commit sha to its subject and files; a parameter so the +# filter can be tested without a repository. +filter_app_news <- function(lines, inspect = inspect_commit) { + app <- logical(length(lines)) + i <- 1L + while (i <= length(lines)) { + if (startsWith(lines[[i]], "- ")) { + end <- i + while (end < length(lines) && startsWith(lines[[end + 1L]], " ")) { + end <- end + 1L + } + sha <- news_entry_sha(paste(lines[i:end], collapse = "\n")) + if (!is.na(sha) && is_app_commit(inspect(sha))) { + app[i:end] <- TRUE + } + i <- end + 1L + } else { + i <- i + 1L + } + } + if (!any(app)) { + return(lines) + } + + # Each "## " version heading starts a block; the app section goes after the + # block's last entry, before autonewsmd's "Full set of changes" link. + starts <- unique(c(1L, which(startsWith(lines, "## ")))) + ends <- c(starts[-1L] - 1L, length(lines)) + blocks <- Map( + function(from, to) { + rows <- seq.int(from, to) + moved <- lines[rows][app[rows]] + block <- drop_empty_sections(lines[rows][!app[rows]]) + if (length(moved) == 0L) { + return(block) + } + full <- match(TRUE, startsWith(block, "Full set of changes"), nomatch = length(block) + 1L) + filled <- which(nzchar(trimws(block[seq_len(full - 1L)]))) + after <- if (length(filled) > 0L) max(filled) else 0L + append(block, c("", "#### Shiny app", "", moved), after = after) + }, + starts, + ends + ) + unlist(blocks, use.names = FALSE) +} + if (sys.nframe() == 0L) { news <- readLines("NEWS.md", warn = FALSE, encoding = "UTF-8") filtered <- filter_app_news(news) - removed <- sum(startsWith(news, "- ")) - sum(startsWith(filtered, "- ")) writeLines(filtered, "NEWS.md", useBytes = TRUE) - message(sprintf("Removed %d app-only entries from NEWS.md.", removed)) + message(sprintf("Moved app entries into %d Shiny app sections of NEWS.md.", sum(filtered == "#### Shiny app"))) } diff --git a/.github/scripts/sync-app-release.R b/.github/scripts/sync-app-release.R new file mode 100644 index 0000000..228aebc --- /dev/null +++ b/.github/scripts/sync-app-release.R @@ -0,0 +1,58 @@ +# Release the Shiny app in app/ in step with singlehit. +# +# The release workflow runs this after bumping DESCRIPTION and before the +# release commit, so these edits land in that commit and the release tag holds +# both packages at the same version. The app is pinned to singlehit's release +# tag, so `pak::pak("seanthimons/singlehit/app@vX")` installs the matching +# singlehit. When publishing nothing is committed, so the script only checks +# that the merged files already match. + +# `check_only` stops if anything is out of step instead of editing it. +sync_app_release <- function(version, app = "app", check_only = FALSE) { + d <- desc::desc(file.path(app, "DESCRIPTION")) + remotes <- d$get_remotes() + i <- grep("/singlehit(@.*)?$", remotes) + if (length(i) != 1L) { + stop("app/DESCRIPTION needs exactly one singlehit entry in Remotes.") + } + remotes[[i]] <- paste0(sub("@.*$", "", remotes[[i]]), "@v", version) + requirement <- paste(">=", version) + + config_path <- file.path(app, "inst", "golem-config.yml") + old_config <- readLines(config_path, warn = FALSE) + config <- sub("^(\\s*golem_version:).*$", paste("\\1", version), old_config) + + deps <- d$get_deps() + synced <- identical(as.character(d$get_version()), version) && + identical(deps$version[deps$package == "singlehit" & deps$type == "Imports"], requirement) && + identical(d$get_remotes(), remotes) && + identical(old_config, config) + if (check_only) { + if (!synced) { + stop(sprintf( + "app/ is not released in step with singlehit %s: set its Version, singlehit (%s), Remotes %s, and golem_version.", + version, + requirement, + remotes[[i]] + )) + } + return(invisible(FALSE)) + } + + d$set_version(version) + d$set_dep("singlehit", "Imports", requirement) + d$set_remotes(remotes) + d$write() + # desc leaves a trailing space after "Remotes:". + path <- file.path(app, "DESCRIPTION") + writeLines(sub("[ \t]+$", "", readLines(path, warn = FALSE)), path) + writeLines(config, config_path) + invisible(!synced) +} + +if (sys.nframe() == 0L) { + version <- read.dcf("DESCRIPTION", fields = "Version")[[1L]] + publishing <- identical(Sys.getenv("RELEASE_MODE"), "publish") + sync_app_release(version, check_only = publishing) + message(sprintf("app/ %s singlehit %s.", if (publishing) "matches" else "set to", version)) +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 5917f95..9192c06 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -43,14 +43,28 @@ The release caller respects that policy: publishes the release. It does not bump the version or regenerate NEWS. Keep `dry-run: true` for validation; it remains the manual default. PRs that -change `release.yaml` or `.github/scripts/filter-app-news.R` automatically validate -preparation with dry-run enabled. +change `release.yaml`, `.github/scripts/filter-app-news.R`, or +`.github/scripts/sync-app-release.R` automatically validate preparation with +dry-run enabled. Fork PRs skip this job because they cannot receive `RELEASE_PAT`. The old `version_type` input is replaced by `version-type`; `none` is unsupported. -After autonewsmd writes NEWS.md, `.github/scripts/filter-app-news.R` removes entries for -commits scoped `app` or touching only `app/`, since the Shiny app is not part of -the package. NEWS.md is regenerated from history each release, so this also -removes those entries from earlier versions' sections. + +Each release also releases the Shiny app in `app/` at the same version. After +the version bump, `.github/scripts/sync-app-release.R` sets the app's +`Version`, its `singlehit (>= VERSION)` requirement, its +`Remotes: seanthimons/singlehit@vVERSION` pin, and `golem_version` in +`app/inst/golem-config.yml`, so the release commit and tag hold both packages. +`pak::pak("seanthimons/singlehit/app@vVERSION")` then installs the app with the +matching singlehit. In publish mode the script only checks that the merged app +files match and fails the release otherwise. Between releases, the app on +`main` stays pinned to the last singlehit tag. Only the root package's tarball +is built and checked; nothing in CI checks the app. + +After autonewsmd writes NEWS.md, `.github/scripts/filter-app-news.R` moves +entries for commits scoped `app` or touching only `app/` into a `#### Shiny app` +section at the end of their version, so they do not read as singlehit +changes. NEWS.md is regenerated from history each release, so earlier +versions' sections get the same treatment. If branch push succeeds but PR creation fails, open a PR from the existing `release/vVERSION` branch. If publication fails after the tag push, rerun diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f61f8db..c17da9c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,6 +6,7 @@ on: paths: - .github/workflows/release.yaml - .github/scripts/filter-app-news.R + - .github/scripts/sync-app-release.R workflow_dispatch: inputs: release-mode: @@ -39,6 +40,7 @@ jobs: version-type: ${{ inputs.version-type || 'patch' }} notes-mode: autonewsmd news-postprocess-script: .github/scripts/filter-app-news.R + pre-release-check-script: .github/scripts/sync-app-release.R dry-run: ${{ github.event_name == 'pull_request' || inputs.dry-run }} secrets: RELEASE_PAT: ${{ secrets.RELEASE_PAT }} diff --git a/README.md b/README.md index b5b20b7..d5f4f1e 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,19 @@ The package provides: If you would rather not write R code, the `singlehitapp` Shiny app runs the same analysis in your web browser. It is a separate package stored in the [`app/`](https://github.com/seanthimons/singlehit/tree/main/app) directory of -this repository. Installing `singlehit` does not install the app, so install -both: +this repository, and each singlehit release includes the app at the same +version. Installing `singlehit` does not install the app, but installing the +app also installs the singlehit release it was built with: ```r -install.packages("pak") # only needed once -pak::pak("seanthimons/singlehit") # the package (the app needs 0.3.0 or later) -pak::pak("seanthimons/singlehit/app") # the app -singlehitapp::run_app() # opens the app in your browser +install.packages("pak") # only needed once +pak::pak("seanthimons/singlehit/app@*release") # the latest app and its singlehit +singlehitapp::run_app() # opens the app in your browser ``` +To install a specific release, replace `*release` with its tag, for example +`app@v0.4.0`. + The tabs, from left to right: - **Data:** load a CSV/TSV/TXT file or the Ward rotavirus example data. A QMRA diff --git a/app/README.md b/app/README.md index 4cec21f..fb8eaa3 100644 --- a/app/README.md +++ b/app/README.md @@ -123,11 +123,20 @@ export are planned. The app is licensed under GPL-3 (see `LICENSE.md`) because it imports esquisse, which is GPL-3. The `singlehit` package itself stays MIT. -## Requires singlehit 0.3.0 or later +## Releases -The Bootstrap and Plots tabs use `singlehit` functions (`add_bootstrap()`, -`bootstrap_progress()`) that were added in 0.3.0, so install `singlehit` 0.3.0 -or later (for example from GitHub with `pak::pak("seanthimons/singlehit")`). +The app is released with `singlehit`, at the same version and under the same +tag. The release workflow sets the app's version, raises its `singlehit` +requirement to that version, and pins `Remotes` to the release tag, so + +```r +pak::pak("seanthimons/singlehit/app@*release") # or @v0.4.0 for a given release +``` + +installs the app with the `singlehit` release it was built with. Between +releases, the app on `main` stays pinned to the last `singlehit` tag. If app +code on `main` needs unreleased `singlehit` functions, use the development +setup below. ## Running during development @@ -154,8 +163,7 @@ This loads the in-repo `singlehit` first, so the tests do not need it installed. ## Running against an installed singlehit ```r -pak::pak("seanthimons/singlehit") -pak::pak("seanthimons/singlehit/app") +pak::pak("seanthimons/singlehit/app@*release") singlehitapp::run_app() ``` diff --git a/tests/testthat/test-app-release-sync.R b/tests/testthat/test-app-release-sync.R new file mode 100644 index 0000000..26f1082 --- /dev/null +++ b/tests/testthat/test-app-release-sync.R @@ -0,0 +1,48 @@ +# The app version sync lives with the CI scripts, which are not part of the +# built package, so these tests only run from a source checkout. +script <- test_path("..", "..", ".github", "scripts", "sync-app-release.R") +skip_if_not(file.exists(script), "release script is not in the built package") +skip_if_not_installed("desc") +sync_env <- new.env() +sys.source(script, envir = sync_env) + +app_copy <- function() { + app <- tempfile("app") + dir.create(file.path(app, "inst"), recursive = TRUE) + writeLines( + c( + "Package: singlehitapp", + "Version: 0.0.0.9000", + "Imports:", + " shiny,", + " singlehit (>= 0.3.0)", + "Remotes:", + " seanthimons/singlehit" + ), + file.path(app, "DESCRIPTION") + ) + writeLines( + c("default:", " golem_name: singlehitapp", " golem_version: 0.0.0.9000"), + file.path(app, "inst", "golem-config.yml") + ) + app +} + +test_that("syncing sets the app's version, singlehit floor, remote tag, and golem version", { + app <- app_copy() + expect_true(sync_env$sync_app_release("0.4.0", app)) + d <- read.dcf(file.path(app, "DESCRIPTION")) + expect_equal(d[, "Version"], c(Version = "0.4.0")) + expect_match(d[, "Imports"], "singlehit (>= 0.4.0)", fixed = TRUE) + expect_equal(trimws(d[, "Remotes"]), c(Remotes = "seanthimons/singlehit@v0.4.0")) + expect_true(" golem_version: 0.4.0" %in% readLines(file.path(app, "inst", "golem-config.yml"))) + expect_false(any(grepl("[ \t]$", readLines(file.path(app, "DESCRIPTION"))))) + expect_false(sync_env$sync_app_release("0.4.0", app)) +}) + +test_that("check-only mode passes once synced and fails otherwise", { + app <- app_copy() + expect_error(sync_env$sync_app_release("0.4.0", app, check_only = TRUE), "not released in step") + sync_env$sync_app_release("0.4.0", app) + expect_silent(sync_env$sync_app_release("0.4.0", app, check_only = TRUE)) +}) diff --git a/tests/testthat/test-news-filter.R b/tests/testthat/test-news-filter.R index d992490..5f5c465 100644 --- a/tests/testthat/test-news-filter.R +++ b/tests/testthat/test-news-filter.R @@ -5,7 +5,9 @@ skip_if_not(file.exists(script), "release script is not in the built package") filter_env <- new.env() sys.source(script, envir = filter_env) -entry <- function(text, sha) c(paste("-", text), sprintf(" ([%s](https://github.com/o/r/tree/%s))", substr(sha, 1, 7), sha)) +entry <- function(text, sha) { + c(paste("-", text), sprintf(" ([%s](https://github.com/o/r/tree/%s))", substr(sha, 1, 7), sha)) +} sha <- function(letter) strrep(letter, 40) commits <- list( @@ -18,40 +20,114 @@ commits <- list( inspect <- function(sha) commits[[substr(sha, 1, 1)]] run <- function(lines) filter_env$filter_app_news(lines, inspect) -test_that("entries are dropped for the app scope or app-only changes, and kept otherwise", { +test_that("app-scoped and app-only entries move to a Shiny app section; others stay", { news <- c( - "## singlehit v1.0.0 (2026-01-01)", "", "#### New features", "", + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### New features", + "", entry("add a tab", sha("a")), entry("change a thing", sha("b")), entry("add progress", sha("c")), entry("handle limit", sha("d")), - "", "#### Docs", "", entry("readme", sha("e")) + "", + "#### Docs", + "", + entry("readme", sha("e")), + "", + "Full set of changes:", + "[`v0.9.0...v1.0.0`](https://example.org)", + "" + ) + expect_equal( + run(news), + c( + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### New features", + "", + entry("add progress", sha("c")), + entry("handle limit", sha("d")), + "", + "#### Docs", + "", + entry("readme", sha("e")), + "", + "#### Shiny app", + "", + entry("add a tab", sha("a")), + entry("change a thing", sha("b")), + "", + "Full set of changes:", + "[`v0.9.0...v1.0.0`](https://example.org)", + "" + ) ) - out <- run(news) - expect_false(any(grepl(sha("a"), out))) - expect_false(any(grepl(sha("b"), out))) - expect_true(any(grepl(sha("c"), out))) - expect_true(any(grepl(sha("d"), out))) - expect_true(any(grepl(sha("e"), out))) }) -test_that("a section left empty is removed along with its blank line", { +test_that("app entries stay within their own version", { news <- c( - "## singlehit v1.0.0 (2026-01-01)", "", - "#### New features", "", entry("add a tab", sha("a")), "", - "#### Docs", "", entry("readme", sha("e")) + "## singlehit v2.0.0 (2026-02-01)", + "", + "#### Docs", + "", + entry("readme", sha("e")), + "", + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### New features", + "", + entry("add a tab", sha("a")), + "" + ) + expect_equal( + run(news), + c( + "## singlehit v2.0.0 (2026-02-01)", + "", + "#### Docs", + "", + entry("readme", sha("e")), + "", + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### Shiny app", + "", + entry("add a tab", sha("a")), + "" + ) ) - out <- run(news) - expect_false("#### New features" %in% out) - expect_equal(out, c("## singlehit v1.0.0 (2026-01-01)", "", "#### Docs", "", entry("readme", sha("e")))) }) -test_that("a section that keeps any entry keeps its heading", { - news <- c("#### New features", "", entry("add a tab", sha("a")), entry("add progress", sha("c"))) - expect_equal(run(news), c("#### New features", "", entry("add progress", sha("c")))) +test_that("a section left empty is removed along with its blank line", { + news <- c( + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### New features", + "", + entry("add a tab", sha("a")), + "", + "#### Docs", + "", + entry("readme", sha("e")) + ) + expect_equal( + run(news), + c( + "## singlehit v1.0.0 (2026-01-01)", + "", + "#### Docs", + "", + entry("readme", sha("e")), + "", + "#### Shiny app", + "", + entry("add a tab", sha("a")) + ) + ) }) -test_that("entries whose commit cannot be inspected are kept", { +test_that("entries whose commit cannot be inspected stay where they are", { news <- c("#### Fixes", "", entry("mystery", sha("z")), entry("no link", sha("a"))[1]) expect_equal(run(news), news) }) diff --git a/vignettes/articles/shiny-app.Rmd b/vignettes/articles/shiny-app.Rmd index f015593..7dfdc86 100644 --- a/vignettes/articles/shiny-app.Rmd +++ b/vignettes/articles/shiny-app.Rmd @@ -12,14 +12,19 @@ match the package's results. ## Install and launch The app is a separate package stored in the `app/` directory of the singlehit -repository. Run these lines once at the R console: +repository. Each singlehit release includes the app at the same version, and +installing the app also installs the singlehit release it was built with. Run +these lines once at the R console: ```r install.packages("pak") -pak::pak("seanthimons/singlehit") # singlehit 0.3.0 or later -pak::pak("seanthimons/singlehit/app") # the app +pak::pak("seanthimons/singlehit/app@*release") # the latest app and its singlehit ``` +To install a specific release, replace `*release` with its tag, for example +`app@v0.4.0`. This replaces any other version of singlehit you have installed, +including a development version from `main`. + Then start it whenever you want to use it: ```r