diff --git a/doc/changelog/CHANGELOG.md b/doc/changelog/CHANGELOG.md index 76f7648..67a5d57 100644 --- a/doc/changelog/CHANGELOG.md +++ b/doc/changelog/CHANGELOG.md @@ -35,6 +35,26 @@ not deferred to release. `release-tag.sh` promotes `[Unreleased]` → ### Added +- **Five desktop-only modules for the small-tools modularization program: + `vlc`, `ibus-rime`, `cheese`, `v4l-utils`, + `gnome-shell-extension-manager`** (`module/.module.sh` + + `test/unit/module/_spec.bats` each). All five are apt-archetype + (`module_use_apt_archetype`) `optional` modules scoped to + `SUPPORTED_PLATFORMS=("desktop")`, each installing its named apt package + (`vlc`, `ibus-rime`, `cheese`, `v4l-utils`, + `gnome-shell-extension-manager`), with module-defined `detect()` and a + desktop-gated `is_recommended()` (recommended only on the `desktop` form + factor). Each ships a real `doctor()` that goes beyond the dpkg check by + verifying the runtime binary actually resolves on PATH — `command -v vlc`, + `command -v cheese`, `command -v v4l2-ctl` (binary name differs from the + package), `command -v extension-manager` (likewise); `ibus-rime` is a + data/engine package with no like-named binary, so its doctor probes the + `ibus` framework binary plus the Rime engine data directory. Each declares + an en + zh-TW `DESCRIPTION`, `SUPPORTED_UBUNTU=("22.04" "24.04" "26.04")`, + and a `TEST_VERIFY_CMD` matching its doctor probe. Both standalone- and + engine-invocable; all satisfy the 10-function module contract (ADR-0002, + enforced by `test/unit/module/contract_conformance_spec.bats` / #305). + `doc/module/INDEX.md` regenerated (69 modules). - **Six more apt-archetype CLI/utility modules for the small-tools modularization program: `ag`, `asciidoctor`, `xclip`, `xsel`, `ansifilter`, `tealdeer`** (`module/.module.sh` + diff --git a/doc/module/INDEX.md b/doc/module/INDEX.md index e677b49..8c6a421 100644 --- a/doc/module/INDEX.md +++ b/doc/module/INDEX.md @@ -3,7 +3,7 @@ # Module Index -70 modules. Columns come from each module's metadata block +75 modules. Columns come from each module's metadata block (`NAME` / `CATEGORY` / `TAGS` / `DESCRIPTION[en]`, doc/module-spec.md §3). Per-module pages will land in a later milestone; until then each name links to the module source. @@ -18,6 +18,7 @@ to the module source. | [bmon](../../module/bmon.module.sh) | optional | monitoring | bmon — bandwidth monitor (real-time per-interface traffic, apt bmon package) | | [btop](../../module/btop.module.sh) | optional | monitoring | btop — modern resource monitor (CPU / memory / disk / net, apt btop package) | | [build-essential](../../module/build-essential.module.sh) | base | build | build-essential — C/C++ compiler toolchain metapackage (gcc, g++, make) | +| [cheese](../../module/cheese.module.sh) | optional | media | Cheese — GNOME webcam app for photos and videos (apt cheese package) | | [claude-code-config](../../module/claude-code-config.module.sh) | optional | agent config dotfile | Personal Claude Code settings (~/.claude: settings.json + statusline) | | [claude-code](../../module/claude-code.module.sh) | optional | agent | Anthropic Claude Code CLI agent (official native installer, self-updating) | | [claude-monitor](../../module/claude-monitor.module.sh) | optional | agent cli | claude-monitor — real-time Claude Code token/cost usage monitor TUI (pipx) | @@ -37,8 +38,10 @@ to the module source. | [git-lfs](../../module/git-lfs.module.sh) | optional | git vcs | git-lfs — Git Large File Storage (versions large files via pointers) | | [git](../../module/git.module.sh) | base | vcs | git — distributed version control system (apt git package) | | [glow](../../module/glow.module.sh) | optional | cli-essentials | glow — render markdown on the CLI (yazi markdown-preview dependency) | +| [gnome-shell-extension-manager](../../module/gnome-shell-extension-manager.module.sh) | optional | gnome desktop | Extension Manager — browse and manage GNOME Shell extensions (binary: extension-manager) | | [gum](../../module/gum.module.sh) | optional | cli-essentials tui | gum — a tool for glamorous shell scripts (preferred modern TUI backend) | | [htop](../../module/htop.module.sh) | base | monitor | htop — interactive process viewer (apt htop package) | +| [ibus-rime](../../module/ibus-rime.module.sh) | optional | input-method desktop | Rime input method engine for IBus — pulls in the ibus framework (apt ibus-rime package) | | [ifstat](../../module/ifstat.module.sh) | optional | monitoring | ifstat — report network interface bandwidth statistics (apt ifstat package) | | [iftop](../../module/iftop.module.sh) | optional | monitoring | iftop — display network bandwidth usage by host (apt iftop package) | | [iotop](../../module/iotop.module.sh) | optional | monitoring | iotop — per-process disk I/O monitor (apt iotop package) | @@ -73,7 +76,9 @@ to the module source. | [trash-maintenance](../../module/trash-maintenance.module.sh) | optional | maintenance trash cron | Daily trash cleanup: age + size cap (single source of truth vs GNOME auto-delete) | | [tree](../../module/tree.module.sh) | optional | cli filesystem | tree — recursive directory listing as an indented tree | | [unzip](../../module/unzip.module.sh) | base | archive | unzip — extractor for .zip archives (apt unzip package) | +| [v4l-utils](../../module/v4l-utils.module.sh) | optional | media hardware | v4l-utils — Video4Linux tools to inspect and control cameras (binary: v4l2-ctl) | | [vim](../../module/vim.module.sh) | base | editor | vim — Vi IMproved terminal text editor (apt vim package) | +| [vlc](../../module/vlc.module.sh) | optional | media | VLC — cross-platform media player for audio and video (apt vlc package) | | [vscode](../../module/vscode.module.sh) | optional | editor | Visual Studio Code — Microsoft's GUI code editor (Microsoft apt repo) | | [wget](../../module/wget.module.sh) | base | http | wget — non-interactive network downloader (apt wget package) | | [xclip](../../module/xclip.module.sh) | optional | cli clipboard | xclip — command-line interface to the X11 clipboard/selection | diff --git a/module/cheese.module.sh b/module/cheese.module.sh new file mode 100644 index 0000000..64c7266 --- /dev/null +++ b/module/cheese.module.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# module/cheese.module.sh — Cheese — GNOME webcam photo / video app (apt cheese package) [archetype: apt] +# +# Part of the small-tools modularization program: each desktop tool is an +# independently installable / removable module. Ubuntu ships the `cheese` +# package; the binary is `cheese`. Desktop-only (SUPPORTED_PLATFORMS). +# +# Standalone usage: +# bash module/cheese.module.sh install [--dry-run] +# bash module/cheese.module.sh upgrade / remove / purge / verify / doctor +# bash module/cheese.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/cheese.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install cheese + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="cheese" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("media") +HOMEPAGE="https://apps.gnome.org/Cheese/" +declare -gA DESCRIPTION=( + [en]="Cheese — GNOME webcam app for photos and videos (apt cheese package)" + [zh-TW]="Cheese — GNOME 網路攝影機應用程式,可拍照與錄影(apt cheese 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v cheese" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("cheese") +APT_PPA="" +CONFIG_PATHS=("${HOME}/.config/cheese" "${HOME}/.local/share/cheese") +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# Desktop-only recommendation gate (module-spec.md §4.3.1): a webcam GUI app is +# pointless on headless / SBC form factors. +is_recommended() { + case "${INIT_UBUNTU_FORM_FACTOR:-}" in + desktop) + ! is_installed + ;; + *) + return 1 + ;; + esac +} + +# doctor: real runtime health — the tool must be installed AND the binary must +# actually resolve on PATH (`command -v cheese`), catching a half-configured +# dpkg state. Warns (read-only) on Sidecar drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v cheese + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: cheese is not installed"; return 1; } + command -v cheese >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: cheese is present in dpkg but not runnable on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/gnome-shell-extension-manager.module.sh b/module/gnome-shell-extension-manager.module.sh new file mode 100644 index 0000000..3a0ee34 --- /dev/null +++ b/module/gnome-shell-extension-manager.module.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# module/gnome-shell-extension-manager.module.sh — Extension Manager — GNOME Shell extension browser / manager (apt gnome-shell-extension-manager package) [archetype: apt] +# +# Part of the small-tools modularization program: each desktop tool is an +# independently installable / removable module. Ubuntu ships the +# `gnome-shell-extension-manager` package; the binary is `extension-manager` +# (package name != binary name). Desktop-only (SUPPORTED_PLATFORMS): a GNOME +# Shell tool has no meaning on headless / SBC form factors. +# +# Standalone usage: +# bash module/gnome-shell-extension-manager.module.sh install [--dry-run] +# bash module/gnome-shell-extension-manager.module.sh upgrade / remove / purge / verify / doctor +# bash module/gnome-shell-extension-manager.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/gnome-shell-extension-manager.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install gnome-shell-extension-manager + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="gnome-shell-extension-manager" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("gnome" "desktop") +HOMEPAGE="https://github.com/mjakeman/extension-manager" +declare -gA DESCRIPTION=( + [en]="Extension Manager — browse and manage GNOME Shell extensions (binary: extension-manager)" + [zh-TW]="Extension Manager — 瀏覽與管理 GNOME Shell 擴充功能(執行檔:extension-manager)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v extension-manager" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("gnome-shell-extension-manager") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# Desktop-only recommendation gate (module-spec.md §4.3.1): a GNOME Shell tool +# is meaningless on headless / SBC form factors. +is_recommended() { + case "${INIT_UBUNTU_FORM_FACTOR:-}" in + desktop) + ! is_installed + ;; + *) + return 1 + ;; + esac +} + +# doctor: real runtime health — the package must be installed AND its binary +# `extension-manager` must actually resolve on PATH (package name differs from +# the binary, so this catches a partial install). Warns (read-only) on Sidecar +# drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v extension-manager + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: gnome-shell-extension-manager is not installed"; return 1; } + command -v extension-manager >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: installed in dpkg but extension-manager is not on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/ibus-rime.module.sh b/module/ibus-rime.module.sh new file mode 100644 index 0000000..e3286a6 --- /dev/null +++ b/module/ibus-rime.module.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# module/ibus-rime.module.sh — Rime input method engine for IBus (apt ibus-rime package) [archetype: apt] +# +# Part of the small-tools modularization program: each desktop tool is an +# independently installable / removable module. Ubuntu ships the `ibus-rime` +# package, which pulls in the `ibus` framework (binary: `ibus`) plus the Rime +# engine + data. `ibus-rime` itself is a data / engine package with no +# like-named binary, so the runtime probe targets `ibus` and the Rime data +# directory. Desktop-only (SUPPORTED_PLATFORMS): an input method needs a +# graphical session. +# +# Standalone usage: +# bash module/ibus-rime.module.sh install [--dry-run] +# bash module/ibus-rime.module.sh upgrade / remove / purge / verify / doctor +# bash module/ibus-rime.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/ibus-rime.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install ibus-rime + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="ibus-rime" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("input-method" "desktop") +HOMEPAGE="https://rime.im/" +declare -gA DESCRIPTION=( + [en]="Rime input method engine for IBus — pulls in the ibus framework (apt ibus-rime package)" + [zh-TW]="IBus 的 Rime 輸入法引擎,會一併安裝 ibus 框架(apt ibus-rime 套件)" +) +declare -gA POST_INSTALL_MESSAGE=( + [en]="Add Rime as an input source (GNOME Settings > Keyboard) and restart IBus with 'ibus restart'." + [zh-TW]="於「設定 > 鍵盤」新增 Rime 輸入來源,並執行 'ibus restart' 重新啟動 IBus。" +) +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v ibus" + +# Rime engine data directory (shipped by ibus-rime / librime-data). Overridable +# so the doctor probe is deterministic under the unit harness. +RIME_DATA_DIR="${RIME_DATA_DIR:-/usr/share/rime-data}" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("ibus-rime") +APT_PPA="" +CONFIG_PATHS=("${HOME}/.config/ibus/rime") +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# Desktop-only recommendation gate (module-spec.md §4.3.1): an input method +# needs a graphical session, so it is pointless on headless / SBC form factors. +is_recommended() { + case "${INIT_UBUNTU_FORM_FACTOR:-}" in + desktop) + ! is_installed + ;; + *) + return 1 + ;; + esac +} + +# doctor: real runtime health — ibus-rime is a data / engine package (no +# like-named binary), so the probe checks (1) the package is installed, (2) the +# `ibus` framework binary it depends on actually resolves on PATH, and (3) the +# Rime engine data directory exists. Warns (read-only) on Sidecar drift +# (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v ibus + Rime data dir + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: ibus-rime is not installed"; return 1; } + command -v ibus >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: the ibus framework binary is not runnable on PATH"; return 1; } + [[ -d "${RIME_DATA_DIR}" ]] \ + || { log_warn "[${NAME}] doctor: Rime engine data dir missing (${RIME_DATA_DIR})"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/v4l-utils.module.sh b/module/v4l-utils.module.sh new file mode 100644 index 0000000..e7d747c --- /dev/null +++ b/module/v4l-utils.module.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# module/v4l-utils.module.sh — v4l-utils — Video4Linux capture / control tools (apt v4l-utils package) [archetype: apt] +# +# Part of the small-tools modularization program: each desktop tool is an +# independently installable / removable module. Ubuntu ships the `v4l-utils` +# package; the primary binary is `v4l2-ctl` (package name != binary name). +# Desktop-only (SUPPORTED_PLATFORMS): camera / capture tooling for a graphical +# workstation. +# +# Standalone usage: +# bash module/v4l-utils.module.sh install [--dry-run] +# bash module/v4l-utils.module.sh upgrade / remove / purge / verify / doctor +# bash module/v4l-utils.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/v4l-utils.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install v4l-utils + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="v4l-utils" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("media" "hardware") +HOMEPAGE="https://linuxtv.org/wiki/index.php/V4l-utils" +declare -gA DESCRIPTION=( + [en]="v4l-utils — Video4Linux tools to inspect and control cameras (binary: v4l2-ctl)" + [zh-TW]="v4l-utils — Video4Linux 工具組,用於檢視與控制攝影機(執行檔:v4l2-ctl)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v v4l2-ctl" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("v4l-utils") +APT_PPA="" +CONFIG_PATHS=() +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# Desktop-only recommendation gate (module-spec.md §4.3.1): camera / capture +# tooling targets a graphical workstation, not headless / SBC form factors. +is_recommended() { + case "${INIT_UBUNTU_FORM_FACTOR:-}" in + desktop) + ! is_installed + ;; + *) + return 1 + ;; + esac +} + +# doctor: real runtime health — the package must be installed AND its primary +# binary `v4l2-ctl` must actually resolve on PATH (package name differs from the +# binary, so this catches a partial install). Warns (read-only) on Sidecar +# drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v v4l2-ctl + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: v4l-utils is not installed"; return 1; } + command -v v4l2-ctl >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: v4l-utils is present in dpkg but v4l2-ctl is not on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/module/vlc.module.sh b/module/vlc.module.sh new file mode 100644 index 0000000..cd62645 --- /dev/null +++ b/module/vlc.module.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# module/vlc.module.sh — VLC — cross-platform media player (apt vlc package) [archetype: apt] +# +# Part of the small-tools modularization program: each desktop tool is an +# independently installable / removable module. Ubuntu ships the `vlc` +# package; the binary is `vlc`. Desktop-only (SUPPORTED_PLATFORMS). +# +# Standalone usage: +# bash module/vlc.module.sh install [--dry-run] +# bash module/vlc.module.sh upgrade / remove / purge / verify / doctor +# bash module/vlc.module.sh detect / is-installed / is-recommended / is-outdated +# bash module/vlc.module.sh info / status (read-only metadata views) +# +# Engine usage (resolves DEPENDS_ON, batches with state.json): +# setup_ubuntu install vlc + +# ── Dual-mode header ──────────────────────────────────────────────────────── +MODULE_STANDALONE="true" +[[ "${BASH_SOURCE[0]:-}" != "${0:-}" ]] && MODULE_STANDALONE="false" +if [[ "${MODULE_STANDALONE}" == "true" ]]; then + # shellcheck source=../lib/module_bootstrap.sh + source "${LIB_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../lib" && pwd -P)}/module_bootstrap.sh" + module_bootstrap +fi +# Static-analysis hint (never executed: the guard is always false; wrapped in +# kcov-exclude so the dead line is not counted against coverage). module_bootstrap +# sources the lib helpers at runtime, but shellcheck cannot trace that 2-level +# dynamic source — this guarded line lets `shellcheck -x` follow module_helper.sh +# so it sees the metadata + archetype vars below are used externally (avoids SC2034). +# kcov-exclude-start +# shellcheck source=../lib/module_helper.sh +[[ -n "${__module_lint_hint:-}" ]] && source "${LIB_DIR}/module_helper.sh" +# kcov-exclude-end + +# ── Metadata (doc/module-spec.md §3) ──────────────────────────────────────── +NAME="vlc" +VERSION_PROVIDED="apt-managed" +CATEGORY="optional" +TAGS=("media") +HOMEPAGE="https://www.videolan.org/vlc/" +declare -gA DESCRIPTION=( + [en]="VLC — cross-platform media player for audio and video (apt vlc package)" + [zh-TW]="VLC — 跨平台影音媒體播放器,支援音訊與視訊(apt vlc 套件)" +) +declare -gA POST_INSTALL_MESSAGE=() +declare -gA WARN_MESSAGE=() +SUPPORTED_UBUNTU=("22.04" "24.04" "26.04") +SUPPORTED_PLATFORMS=("desktop") +DEPENDS_ON=() +CONFLICTS_WITH=() +SUPPORTS_USER_HOME=false +RISK_LEVEL="low" +REBOOT_REQUIRED=false +INSTALL_TARGET_DEFAULT="sudo" +TEST_VERIFY_CMD="command -v vlc" + +# Engine-consumed metadata: the registry/runner read these post-source, and +# the i18n arrays are dereferenced indirectly via module_i18n_get. Reference +# them once so the linter sees an in-file use (SC2034) without a disable. +: "${DESCRIPTION[*]:-}" "${POST_INSTALL_MESSAGE[*]:-}" "${WARN_MESSAGE[*]:-}" \ + "${SUPPORTS_USER_HOME}" "${INSTALL_TARGET_DEFAULT}" + +# ── Archetype A — apt ─────────────────────────────────────────────────────── +APT_PKGS=("vlc") +APT_PPA="" +CONFIG_PATHS=("${HOME}/.config/vlc" "${HOME}/.local/share/vlc") +module_use_apt_archetype + +# ── Required hooks (detect + is_recommended stay module-specific) ──────────── +detect() { + command -v apt-get >/dev/null 2>&1 +} + +# Desktop-only recommendation gate (module-spec.md §4.3.1): a GUI media player +# is pointless on headless / SBC form factors. +is_recommended() { + case "${INIT_UBUNTU_FORM_FACTOR:-}" in + desktop) + ! is_installed + ;; + *) + return 1 + ;; + esac +} + +# doctor: real runtime health — the tool must be installed AND the binary must +# actually resolve on PATH (`command -v vlc`), catching a half-configured dpkg +# state. Warns (read-only) on Sidecar drift (ADR-0001). +doctor() { + module_dryrun_guard doctor "is_installed + command -v vlc + Sidecar consistency" \ + && return 0 + is_installed || { log_warn "[${NAME}] doctor: vlc is not installed"; return 1; } + command -v vlc >/dev/null 2>&1 \ + || { log_warn "[${NAME}] doctor: vlc is present in dpkg but not runnable on PATH"; return 1; } + if ! module_sidecar_get_version "${NAME}" >/dev/null 2>&1; then + log_warn "[${NAME}] doctor: Sidecar missing (ADR-0001 drift; re-run install/upgrade to heal)" + fi + return 0 +} + +# ── Standalone footer ─────────────────────────────────────────────────────── +if [[ "${MODULE_STANDALONE:-false}" == "true" ]]; then + module_standalone_main "$@" +fi diff --git a/test/unit/module/cheese_spec.bats b/test/unit/module/cheese_spec.bats new file mode 100644 index 0000000..fd14cb6 --- /dev/null +++ b/test/unit/module/cheese_spec.bats @@ -0,0 +1,539 @@ +#!/usr/bin/env bats +# test/unit/module/cheese_spec.bats — module/cheese.module.sh +# +# Desktop-only apt-archetype tool split into its own module (small-tools +# modularization program). Mirrors the nmap archetype-A spec: smoke / metadata +# / lifecycle dry-run / no-side-fx / idempotency / standalone CLI / sidecar +# lifecycle (ADR-0001) / desktop-only is_recommended gate / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/cheese.module.sh + source "${MODULE_DIR}/cheese.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/cheese.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args. +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\n"\nexit 0\n' "${1}" > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "cheese module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/cheese.module.sh" + assert_success +} + +@test "cheese module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "cheese module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "cheese module declares NAME=cheese" { + _load_module + [[ "${NAME}" == "cheese" ]] +} + +@test "cheese module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "cheese module TAGS contains media" { + _load_module + [[ " ${TAGS[*]} " == *" media "* ]] +} + +@test "cheese module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "cheese DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "cheese module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"攝影機"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "cheese SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "cheese SUPPORTED_PLATFORMS is desktop-only" { + _load_module + [[ "${#SUPPORTED_PLATFORMS[@]}" -eq 1 ]] + [[ "${SUPPORTED_PLATFORMS[0]}" == "desktop" ]] +} + +@test "cheese module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "cheese archetype data installs the cheese apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" cheese "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +@test "cheese TEST_VERIFY_CMD probes the cheese binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"cheese"* ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report cheese" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports cheese as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii cheese 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/cheese" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/cheese" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/cheese")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/cheese")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/cheese" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/cheese" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/cheese")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/cheese" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/cheese" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/cheese" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/cheese" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the cheese binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the cheese binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin cheese + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports cheese upgradable" { + _load_module + MOCK_APT_UPGRADABLE='cheese/noble-updates 7.94 amd64 [upgradable from: 7.93]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when cheese is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended (desktop-only gate, module-spec §4.3.1) ─────────────────── + +@test "is_recommended is zero on desktop when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_success +} + +@test "is_recommended is nonzero on desktop when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_failure +} + +@test "is_recommended is nonzero on headless form factors (server / wsl)" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=server run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=wsl run is_recommended + assert_failure +} + +@test "is_recommended is nonzero when the form factor is unknown or unset" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR='' run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=rpi-5 run is_recommended + assert_failure +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "cheese" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: cheese" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "攝影機" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/gnome-shell-extension-manager_spec.bats b/test/unit/module/gnome-shell-extension-manager_spec.bats new file mode 100644 index 0000000..67cfd01 --- /dev/null +++ b/test/unit/module/gnome-shell-extension-manager_spec.bats @@ -0,0 +1,539 @@ +#!/usr/bin/env bats +# test/unit/module/gnome-shell-extension-manager_spec.bats — module/gnome-shell-extension-manager.module.sh +# +# Desktop-only apt-archetype tool split into its own module (small-tools +# modularization program). Mirrors the nmap archetype-A spec: smoke / metadata +# / lifecycle dry-run / no-side-fx / idempotency / standalone CLI / sidecar +# lifecycle (ADR-0001) / desktop-only is_recommended gate / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/gnome-shell-extension-manager.module.sh + source "${MODULE_DIR}/gnome-shell-extension-manager.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/gnome-shell-extension-manager.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args. +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\n"\nexit 0\n' "${1}" > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "gnome-shell-extension-manager module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/gnome-shell-extension-manager.module.sh" + assert_success +} + +@test "gnome-shell-extension-manager module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "gnome-shell-extension-manager module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "gnome-shell-extension-manager module declares NAME=gnome-shell-extension-manager" { + _load_module + [[ "${NAME}" == "gnome-shell-extension-manager" ]] +} + +@test "gnome-shell-extension-manager module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "gnome-shell-extension-manager module TAGS contains gnome" { + _load_module + [[ " ${TAGS[*]} " == *" gnome "* ]] +} + +@test "gnome-shell-extension-manager module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "gnome-shell-extension-manager DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "gnome-shell-extension-manager module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"擴充功能"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "gnome-shell-extension-manager SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "gnome-shell-extension-manager SUPPORTED_PLATFORMS is desktop-only" { + _load_module + [[ "${#SUPPORTED_PLATFORMS[@]}" -eq 1 ]] + [[ "${SUPPORTED_PLATFORMS[0]}" == "desktop" ]] +} + +@test "gnome-shell-extension-manager module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "gnome-shell-extension-manager archetype data installs the gnome-shell-extension-manager apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" gnome-shell-extension-manager "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +@test "gnome-shell-extension-manager TEST_VERIFY_CMD probes the extension-manager binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"extension-manager"* ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report gnome-shell-extension-manager" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports gnome-shell-extension-manager as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii gnome-shell-extension-manager 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/gnome-shell-extension-manager" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the extension-manager binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the extension-manager binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin extension-manager + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports gnome-shell-extension-manager upgradable" { + _load_module + MOCK_APT_UPGRADABLE='gnome-shell-extension-manager/noble-updates 7.94 amd64 [upgradable from: 7.93]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when gnome-shell-extension-manager is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended (desktop-only gate, module-spec §4.3.1) ─────────────────── + +@test "is_recommended is zero on desktop when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_success +} + +@test "is_recommended is nonzero on desktop when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_failure +} + +@test "is_recommended is nonzero on headless form factors (server / wsl)" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=server run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=wsl run is_recommended + assert_failure +} + +@test "is_recommended is nonzero when the form factor is unknown or unset" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR='' run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=rpi-5 run is_recommended + assert_failure +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "gnome-shell-extension-manager" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: gnome-shell-extension-manager" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "擴充功能" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/ibus-rime_spec.bats b/test/unit/module/ibus-rime_spec.bats new file mode 100644 index 0000000..dd64ab2 --- /dev/null +++ b/test/unit/module/ibus-rime_spec.bats @@ -0,0 +1,551 @@ +#!/usr/bin/env bats +# test/unit/module/ibus-rime_spec.bats — module/ibus-rime.module.sh +# +# Desktop-only apt-archetype tool split into its own module (small-tools +# modularization program). Mirrors the nmap archetype-A spec: smoke / metadata +# / lifecycle dry-run / no-side-fx / idempotency / standalone CLI / sidecar +# lifecycle (ADR-0001) / desktop-only is_recommended gate / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/ibus-rime.module.sh + source "${MODULE_DIR}/ibus-rime.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/ibus-rime.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args. +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\n"\nexit 0\n' "${1}" > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "ibus-rime module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/ibus-rime.module.sh" + assert_success +} + +@test "ibus-rime module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "ibus-rime module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "ibus-rime module declares NAME=ibus-rime" { + _load_module + [[ "${NAME}" == "ibus-rime" ]] +} + +@test "ibus-rime module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "ibus-rime module TAGS contains input-method" { + _load_module + [[ " ${TAGS[*]} " == *" input-method "* ]] +} + +@test "ibus-rime module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "ibus-rime DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "ibus-rime module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"輸入法"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "ibus-rime SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "ibus-rime SUPPORTED_PLATFORMS is desktop-only" { + _load_module + [[ "${#SUPPORTED_PLATFORMS[@]}" -eq 1 ]] + [[ "${SUPPORTED_PLATFORMS[0]}" == "desktop" ]] +} + +@test "ibus-rime module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "ibus-rime archetype data installs the ibus-rime apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" ibus-rime "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +@test "ibus-rime TEST_VERIFY_CMD probes the ibus binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"ibus"* ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report ibus-rime" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports ibus-rime as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii ibus-rime 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/ibus-rime" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the ibus binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the ibus binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin ibus + RIME_DATA_DIR="${INIT_UBUNTU_TEST_SCRATCH}/rime-data" + mkdir -p "${RIME_DATA_DIR}" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "ibus-rime doctor fails when the Rime data dir is missing" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin ibus + RIME_DATA_DIR="${INIT_UBUNTU_TEST_SCRATCH}/nonexistent-rime-data" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_failure +} + +@test "is_outdated returns zero when apt reports ibus-rime upgradable" { + _load_module + MOCK_APT_UPGRADABLE='ibus-rime/noble-updates 7.94 amd64 [upgradable from: 7.93]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when ibus-rime is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended (desktop-only gate, module-spec §4.3.1) ─────────────────── + +@test "is_recommended is zero on desktop when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_success +} + +@test "is_recommended is nonzero on desktop when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_failure +} + +@test "is_recommended is nonzero on headless form factors (server / wsl)" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=server run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=wsl run is_recommended + assert_failure +} + +@test "is_recommended is nonzero when the form factor is unknown or unset" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR='' run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=rpi-5 run is_recommended + assert_failure +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "ibus-rime" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: ibus-rime" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "輸入法" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/v4l-utils_spec.bats b/test/unit/module/v4l-utils_spec.bats new file mode 100644 index 0000000..9cc3f90 --- /dev/null +++ b/test/unit/module/v4l-utils_spec.bats @@ -0,0 +1,539 @@ +#!/usr/bin/env bats +# test/unit/module/v4l-utils_spec.bats — module/v4l-utils.module.sh +# +# Desktop-only apt-archetype tool split into its own module (small-tools +# modularization program). Mirrors the nmap archetype-A spec: smoke / metadata +# / lifecycle dry-run / no-side-fx / idempotency / standalone CLI / sidecar +# lifecycle (ADR-0001) / desktop-only is_recommended gate / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/v4l-utils.module.sh + source "${MODULE_DIR}/v4l-utils.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/v4l-utils.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args. +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\n"\nexit 0\n' "${1}" > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "v4l-utils module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/v4l-utils.module.sh" + assert_success +} + +@test "v4l-utils module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "v4l-utils module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "v4l-utils module declares NAME=v4l-utils" { + _load_module + [[ "${NAME}" == "v4l-utils" ]] +} + +@test "v4l-utils module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "v4l-utils module TAGS contains media" { + _load_module + [[ " ${TAGS[*]} " == *" media "* ]] +} + +@test "v4l-utils module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "v4l-utils DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "v4l-utils module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"工具組"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "v4l-utils SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "v4l-utils SUPPORTED_PLATFORMS is desktop-only" { + _load_module + [[ "${#SUPPORTED_PLATFORMS[@]}" -eq 1 ]] + [[ "${SUPPORTED_PLATFORMS[0]}" == "desktop" ]] +} + +@test "v4l-utils module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "v4l-utils archetype data installs the v4l-utils apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" v4l-utils "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +@test "v4l-utils TEST_VERIFY_CMD probes the v4l2-ctl binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"v4l2-ctl"* ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report v4l-utils" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports v4l-utils as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii v4l-utils 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/v4l-utils" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the v4l2-ctl binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the v4l2-ctl binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin v4l2-ctl + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports v4l-utils upgradable" { + _load_module + MOCK_APT_UPGRADABLE='v4l-utils/noble-updates 7.94 amd64 [upgradable from: 7.93]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when v4l-utils is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended (desktop-only gate, module-spec §4.3.1) ─────────────────── + +@test "is_recommended is zero on desktop when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_success +} + +@test "is_recommended is nonzero on desktop when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_failure +} + +@test "is_recommended is nonzero on headless form factors (server / wsl)" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=server run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=wsl run is_recommended + assert_failure +} + +@test "is_recommended is nonzero when the form factor is unknown or unset" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR='' run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=rpi-5 run is_recommended + assert_failure +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "v4l-utils" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: v4l-utils" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "工具組" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} diff --git a/test/unit/module/vlc_spec.bats b/test/unit/module/vlc_spec.bats new file mode 100644 index 0000000..5f2e8c6 --- /dev/null +++ b/test/unit/module/vlc_spec.bats @@ -0,0 +1,539 @@ +#!/usr/bin/env bats +# test/unit/module/vlc_spec.bats — module/vlc.module.sh +# +# Desktop-only apt-archetype tool split into its own module (small-tools +# modularization program). Mirrors the nmap archetype-A spec: smoke / metadata +# / lifecycle dry-run / no-side-fx / idempotency / standalone CLI / sidecar +# lifecycle (ADR-0001) / desktop-only is_recommended gate / real doctor probe. + +load "${BATS_TEST_DIRNAME}/../../helper/common" + +setup() { + setup_test_env + export LOG_LEVEL=INFO + export LOG_COLOR=false +} + +teardown() { + teardown_test_env +} + +_load_module() { + # shellcheck source=../../../lib/logger.sh + source "${LIB_DIR}/logger.sh" + # shellcheck source=../../../lib/general.sh + source "${LIB_DIR}/general.sh" + # shellcheck source=../../../lib/module_helper.sh + source "${LIB_DIR}/module_helper.sh" + # shellcheck source=../../../module/vlc.module.sh + source "${MODULE_DIR}/vlc.module.sh" +} + +_standalone_module() { + bash "${MODULE_DIR}/vlc.module.sh" "$@" +} + +# ── Controllable mocks ─────────────────────────────────────────────────────── + +_mock_is_installed() { + is_installed() { return "${MOCK_IS_INSTALLED_RC:-0}"; } +} + +_mock_dpkg() { + dpkg() { + [[ -n "${MOCK_DPKG_OUTPUT:-}" ]] && printf '%s\n' "${MOCK_DPKG_OUTPUT}" + return "${MOCK_DPKG_RC:-0}" + } +} + +_mock_apt_defaults() { + module_default_apt_install() { return "${MOCK_APT_INSTALL_RC:-0}"; } + module_default_apt_upgrade() { return "${MOCK_APT_UPGRADE_RC:-0}"; } + module_default_apt_remove() { return "${MOCK_APT_REMOVE_RC:-0}"; } + module_default_apt_purge() { return "${MOCK_APT_PURGE_RC:-0}"; } +} + +_mock_dpkg_query() { + dpkg-query() { + [[ -n "${MOCK_PKG_VERSION:-}" ]] || return 1 + printf '%s' "${MOCK_PKG_VERSION}" + } +} + +_mock_apt_list() { + apt() { printf '%s' "${MOCK_APT_UPGRADABLE:-}"; } +} + +# _stub_bin — put a fake binary on PATH that exits 0 for any args. +_stub_bin() { + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/usr/bin/env bash\nprintf "%s stub 0.0.0\n"\nexit 0\n' "${1}" > "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/${1}" +} + +# ── Smoke ──────────────────────────────────────────────────────────────────── + +@test "vlc module file parses (bash -n)" { + run bash -n "${MODULE_DIR}/vlc.module.sh" + assert_success +} + +@test "vlc module sources cleanly in engine mode (MODULE_STANDALONE=false)" { + _load_module + [[ "${MODULE_STANDALONE}" == "false" ]] +} + +@test "vlc module defines all 10 lifecycle functions (ADR-0002 / #305)" { + _load_module + local _fn + for _fn in detect is_recommended is_installed install upgrade remove purge verify is_outdated doctor; do + declare -F "${_fn}" >/dev/null || { + printf 'missing lifecycle function: %s\n' "${_fn}" >&2 + return 1 + } + done +} + +# ── Metadata sanity ────────────────────────────────────────────────────────── + +@test "vlc module declares NAME=vlc" { + _load_module + [[ "${NAME}" == "vlc" ]] +} + +@test "vlc module CATEGORY=optional" { + _load_module + [[ "${CATEGORY}" == "optional" ]] +} + +@test "vlc module TAGS contains media" { + _load_module + [[ " ${TAGS[*]} " == *" media "* ]] +} + +@test "vlc module DEPENDS_ON is empty (standalone tool)" { + _load_module + [[ "${#DEPENDS_ON[@]}" -eq 0 ]] +} + +@test "vlc DESCRIPTION is associative with en + zh-TW entries" { + _load_module + local _decl; _decl="$(declare -p DESCRIPTION 2>/dev/null)" + [[ "${_decl}" == 'declare -'*A* ]] + [[ -n "${DESCRIPTION[en]}" ]] + [[ -n "${DESCRIPTION[zh-TW]}" ]] +} + +@test "vlc module_get_description returns language-specific text + en fallback" { + _load_module + [[ -n "$(module_get_description en)" ]] + [[ "$(module_get_description zh-TW)" == *"媒體播放器"* ]] + [[ "$(module_get_description xx)" == "$(module_get_description en)" ]] +} + +@test "vlc SUPPORTED_UBUNTU includes 22.04 24.04 26.04" { + _load_module + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 22.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 24.04 "* ]] + [[ " ${SUPPORTED_UBUNTU[*]} " == *" 26.04 "* ]] +} + +@test "vlc SUPPORTED_PLATFORMS is desktop-only" { + _load_module + [[ "${#SUPPORTED_PLATFORMS[@]}" -eq 1 ]] + [[ "${SUPPORTED_PLATFORMS[0]}" == "desktop" ]] +} + +@test "vlc module RISK_LEVEL=low and VERSION_PROVIDED=apt-managed" { + _load_module + [[ "${RISK_LEVEL}" == "low" ]] + [[ "${VERSION_PROVIDED}" == "apt-managed" ]] +} + +@test "vlc archetype data installs the vlc apt package" { + _load_module + [[ " ${APT_PKGS[*]} " == *" vlc "* ]] + [[ "${#APT_PKGS[@]}" -eq 1 ]] + [[ -z "${APT_PPA}" ]] +} + +@test "vlc TEST_VERIFY_CMD probes the vlc binary" { + _load_module + [[ "${TEST_VERIFY_CMD}" == *"vlc"* ]] +} + +# ── is_installed ───────────────────────────────────────────────────────────── + +@test "is_installed returns nonzero when dpkg does not report vlc" { + _load_module + MOCK_DPKG_RC=1 + _mock_dpkg + run is_installed + assert_failure +} + +@test "is_installed returns zero when dpkg reports vlc as ii" { + _load_module + MOCK_DPKG_OUTPUT='ii vlc 99.9.9 amd64 pkg' + _mock_dpkg + run is_installed + assert_success +} + +# ── Lifecycle dry-run ──────────────────────────────────────────────────────── + +@test "install in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + assert_output --partial "DRY-RUN" +} + +@test "upgrade in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run upgrade + assert_success + assert_output --partial "DRY-RUN" +} + +@test "remove in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run remove + assert_success + assert_output --partial "DRY-RUN" +} + +@test "purge in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run purge + assert_success + assert_output --partial "DRY-RUN" +} + +@test "verify in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run verify + assert_success + assert_output --partial "DRY-RUN" +} + +@test "doctor in dry-run mode is a no-op" { + _load_module + INIT_UBUNTU_DRY_RUN=true run doctor + assert_success + assert_output --partial "DRY-RUN" +} + +# ── No side effects under dry-run ──────────────────────────────────────────── + +@test "dry-run install writes nothing under the state dir" { + _load_module + INIT_UBUNTU_DRY_RUN=true run install + assert_success + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/vlc" ]] + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/state.json" ]] +} + +@test "standalone dry-run install creates no files in a scratch HOME" { + local _home="${INIT_UBUNTU_TEST_SCRATCH}/home" + mkdir -p "${_home}" + HOME="${_home}" XDG_STATE_HOME="${_home}/.local/state" run _standalone_module install --dry-run + assert_success + local _leftover + _leftover="$(find "${_home}" -mindepth 1 2>/dev/null)" + [[ -z "${_leftover}" ]] +} + +# ── Sidecar lifecycle (ADR-0001) ───────────────────────────────────────────── + +@test "install writes the sidecar with the dpkg-reported version" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ -f "${INIT_UBUNTU_STATE_DIR}/versions/vlc" ]] + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/vlc")" == "1.2.3" ]] +} + +@test "install sidecar falls back to apt-managed when dpkg-query is empty" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/vlc")" == "apt-managed" ]] +} + +@test "install never touches state.json (ADR-0001)" { + _load_module + printf '{"version":"0.2.0","installed":{}}\n' > "${INIT_UBUNTU_STATE_DIR}/state.json" + local _before; _before="$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main install + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/state.json")" == "${_before}" ]] +} + +@test "failed apt install leaves no sidecar behind (ADR-0015)" { + _load_module + MOCK_APT_INSTALL_RC=1 + _mock_apt_defaults + run module_standalone_main install + assert_failure + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/vlc" ]] +} + +@test "upgrade refreshes the sidecar version" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/vlc" + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + module_standalone_main upgrade + [[ "$(cat "${INIT_UBUNTU_STATE_DIR}/versions/vlc")" == "1.2.3" ]] +} + +@test "remove deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/vlc" + _mock_apt_defaults + module_standalone_main remove + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/vlc" ]] +} + +@test "purge deletes the sidecar" { + _load_module + mkdir -p "${INIT_UBUNTU_STATE_DIR}/versions" + printf '1.0.0\n' > "${INIT_UBUNTU_STATE_DIR}/versions/vlc" + _mock_apt_defaults + module_standalone_main purge + [[ ! -e "${INIT_UBUNTU_STATE_DIR}/versions/vlc" ]] +} + +# ── Idempotency ────────────────────────────────────────────────────────────── + +@test "install short-circuits when is_installed returns 0 (idempotency)" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + assert_output --partial "already installed" +} + +@test "install twice with apt mocked exits 0 both times" { + _load_module + _mock_apt_defaults + MOCK_PKG_VERSION="1.2.3" + _mock_dpkg_query + run install + assert_success + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + run install + assert_success +} + +@test "remove is idempotent — second run still exits 0" { + _load_module + _mock_apt_defaults + run remove + assert_success + run remove + assert_success +} + +# ── verify / doctor / is_outdated ──────────────────────────────────────────── + +@test "verify fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run verify + assert_failure +} + +@test "verify passes when installed and TEST_VERIFY_CMD succeeds" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + TEST_VERIFY_CMD="true" + run verify + assert_success +} + +@test "doctor fails when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + run doctor + assert_failure +} + +@test "doctor fails when installed but the vlc binary is not runnable" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run doctor + assert_failure +} + +@test "doctor passes when installed and the vlc binary runs" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + _stub_bin vlc + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run doctor + assert_success +} + +@test "is_outdated returns zero when apt reports vlc upgradable" { + _load_module + MOCK_APT_UPGRADABLE='vlc/noble-updates 7.94 amd64 [upgradable from: 7.93]' + _mock_apt_list + run is_outdated + assert_success +} + +@test "is_outdated returns nonzero when vlc is not in the upgradable list" { + _load_module + MOCK_APT_UPGRADABLE='some-other-pkg/noble 1.0 amd64 [upgradable from: 0.9]' + _mock_apt_list + run is_outdated + assert_failure +} + +# ── is_recommended (desktop-only gate, module-spec §4.3.1) ─────────────────── + +@test "is_recommended is zero on desktop when not installed" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_success +} + +@test "is_recommended is nonzero on desktop when already installed" { + _load_module + MOCK_IS_INSTALLED_RC=0 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=desktop run is_recommended + assert_failure +} + +@test "is_recommended is nonzero on headless form factors (server / wsl)" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR=server run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=wsl run is_recommended + assert_failure +} + +@test "is_recommended is nonzero when the form factor is unknown or unset" { + _load_module + MOCK_IS_INSTALLED_RC=1 + _mock_is_installed + INIT_UBUNTU_FORM_FACTOR='' run is_recommended + assert_failure + INIT_UBUNTU_FORM_FACTOR=rpi-5 run is_recommended + assert_failure +} + +# ── detect ─────────────────────────────────────────────────────────────────── + +@test "detect succeeds when apt-get is available" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/bin" + printf '#!/bin/sh\nexit 0\n' > "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + chmod +x "${INIT_UBUNTU_TEST_SCRATCH}/bin/apt-get" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/bin:${PATH}" run detect + assert_success +} + +@test "detect fails when apt-get is not on PATH" { + _load_module + mkdir -p "${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" + PATH="${INIT_UBUNTU_TEST_SCRATCH}/empty-bin" run detect + assert_failure +} + +# ── Dual-mode standalone CLI ───────────────────────────────────────────────── + +@test "standalone: with no args prints usage + exits 2" { + run _standalone_module + assert_failure 2 + assert_output --partial "Usage:" +} + +@test "standalone: --help shows phases" { + run _standalone_module --help + assert_success + assert_output --partial "install" + assert_output --partial "remove" + assert_output --partial "purge" +} + +@test "standalone: --version prints NAME + VERSION_PROVIDED" { + run _standalone_module --version + assert_success + assert_output --partial "vlc" +} + +@test "standalone: unknown phase returns exit 2" { + run _standalone_module nope + assert_failure 2 +} + +@test "standalone: info prints metadata" { + run _standalone_module info + assert_success + assert_output --partial "name: vlc" + assert_output --partial "category: optional" +} + +@test "standalone: info --lang=zh-TW prints localized description" { + run _standalone_module info --lang=zh-TW + assert_success + assert_output --partial "媒體播放器" +} + +@test "standalone: status reports installed + outdated fields" { + run _standalone_module status + assert_success + assert_output --partial "installed:" + assert_output --partial "outdated:" +} + +# ── AC-25: all 10 phases runnable, never "not implemented" exit 2 ──────────── + +@test "standalone: install --dry-run exits 0 with DRY-RUN output" { + run _standalone_module install --dry-run + assert_success + assert_output --partial "DRY-RUN" +} + +@test "standalone: detect is implemented (exit != 2)" { + run _standalone_module detect + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: is-installed is implemented (exit != 2)" { + run _standalone_module is-installed + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +} + +@test "standalone: doctor is implemented (exit != 2)" { + run _standalone_module doctor + [[ "${status}" -ne 2 ]] + refute_output --partial "not implemented" +}