diff --git a/README.md b/README.md index b993852..69f330a 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,35 @@ assert before plugin_unloaded loaded user_changed after For each resource, this assertion restores the pre-load value when the user did not change the plugin-owned value, otherwise it requires unload to preserve the user's newer value. Use separate clean-shell tests for hostile initial state, -partial initialization failure, and interactive behavior. The repository's -`tests/_support/plugin-contract/scenario.zsh` demonstrates repeated source, -partial failure, hostile state, post-load changes, and both `zsh -f` and -`zsh -f -i` execution. +partial initialization failure, and interactive behavior. + +Repeated source does not prove that a plugin works after it was unloaded. In +another clean shell, load, unload, and load again, then compare the second +load with the first and observe that each restored handler, hook, widget or +other resource still does its job; its presence or a zero status alone would +accept an inert stub. Every restored resource with its own behavior needs its +own check: + +```zsh +zunit_plugin_contract_prime +source ./example.plugin.zsh +zunit_plugin_contract_snapshot loaded + +example_plugin_unload +source ./example.plugin.zsh +zunit_plugin_contract_snapshot reloaded +assert loaded plugin_restored reloaded + +run example_refresh +assert $state equals 0 +assert "$output" same_as 'refreshed' +``` + +The repository's `tests/_support/plugin-contract/scenario.zsh` demonstrates +repeated source, partial failure, hostile state, post-load changes, reload +after unload, and both `zsh -f` and `zsh -f -i` execution. Its reload +scenario checks the fixture's one callback, which the fixture's hook and +widget both dispatch to. ## 📖 Documentation diff --git a/tests/_support/plugin-contract/fixture.plugin.zsh b/tests/_support/plugin-contract/fixture.plugin.zsh index aebde60..207916f 100644 --- a/tests/_support/plugin-contract/fixture.plugin.zsh +++ b/tests/_support/plugin-contract/fixture.plugin.zsh @@ -15,7 +15,9 @@ _contract_fixture_callback() { builtin emulate -L zsh - : + # Observable effect, so a reload test can prove the callback still works. + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 } typeset -g _contract_fixture_callback_body=${functions[_contract_fixture_callback]} typeset -g _contract_fixture_value=plugin diff --git a/tests/_support/plugin-contract/reload-broken.plugin.zsh b/tests/_support/plugin-contract/reload-broken.plugin.zsh new file mode 100644 index 0000000..37dc62a --- /dev/null +++ b/tests/_support/plugin-contract/reload-broken.plugin.zsh @@ -0,0 +1,57 @@ +# Negative fixture: the shipped fixture with the reload defect of +# z-shell/z-a-meta-plugins#54. Load defines its callback only when the name +# is free, and unload leaves the callback as an inert stub instead of removing +# it (as a plugin must when a manager can still dispatch to the name). The +# first load and repeated source are correct; after unload and a second load +# the stub survives, so the callback exists and returns 0 but does nothing. +() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + (( ${+parameters[_contract_fixture_active]} )) && return 0 + + (( ${+functions[_contract_fixture_callback]} )) || _contract_fixture_callback() { + builtin emulate -L zsh + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 + } + typeset -g _contract_fixture_value=plugin + + typeset -gi _contract_fixture_owned_hook=0 + if (( ${precmd_functions[(Ie)_contract_fixture_callback]:-0} == 0 )); then + precmd_functions+=(_contract_fixture_callback) + _contract_fixture_owned_hook=1 + fi + + typeset -gi _contract_fixture_interactive=0 + if [[ -o interactive ]]; then + zle -N contract-fixture-widget _contract_fixture_callback + bindkey -N contract-fixture-map emacs + bindkey -M contract-fixture-map '^Xz' contract-fixture-widget + _contract_fixture_interactive=1 + fi + + contract_fixture_plugin_unload() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + [[ ${_contract_fixture_value-} == plugin ]] && unset _contract_fixture_value + if (( _contract_fixture_owned_hook )); then + precmd_functions=(${precmd_functions:#_contract_fixture_callback}) + (( ${#precmd_functions} )) || unset precmd_functions + fi + if (( _contract_fixture_interactive )); then + bindkey -D contract-fixture-map + zle -D contract-fixture-widget + fi + + # The defect: keep the name defined but inert. + functions[_contract_fixture_callback]='return 0' + + unset _contract_fixture_active _contract_fixture_owned_hook + unset _contract_fixture_interactive + unfunction contract_fixture_plugin_unload + } + + typeset -gi _contract_fixture_active=1 +} diff --git a/tests/_support/plugin-contract/reload-stale.plugin.zsh b/tests/_support/plugin-contract/reload-stale.plugin.zsh new file mode 100644 index 0000000..c1b3309 --- /dev/null +++ b/tests/_support/plugin-contract/reload-stale.plugin.zsh @@ -0,0 +1,36 @@ +# Negative fixture: a plugin whose reloaded surface matches its first load +# while its callback no longer works. A one-time setup is guarded by a flag +# that unload forgets to clear, so a second load skips it. The setup's state +# lives under a name the contract observer excludes (`_zunit_*`), standing +# in for state no snapshot family covers, such as a file or a daemon. After +# unload and a second load the callback exists, returns 0 and is +# byte-identical, but its effect is gone, so only an effect check catches it. +() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + (( ${+parameters[_contract_fixture_active]} )) && return 0 + + if (( ! ${+parameters[_zunit_reload_stale_setup_done]} )); then + typeset -g _zunit_reload_stale_ready=1 + typeset -gi _zunit_reload_stale_setup_done=1 + fi + + _contract_fixture_callback() { + builtin emulate -L zsh + (( ${_zunit_reload_stale_ready:-0} )) || return 0 + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 + } + + contract_fixture_plugin_unload() { + builtin emulate -L zsh + unset _zunit_reload_stale_ready + unfunction _contract_fixture_callback + # The defect: _zunit_reload_stale_setup_done is left set. + unset _contract_fixture_active + unfunction contract_fixture_plugin_unload + } + + typeset -gi _contract_fixture_active=1 +} diff --git a/tests/_support/plugin-contract/scenario.zsh b/tests/_support/plugin-contract/scenario.zsh index d29e54a..ba5d8d1 100644 --- a/tests/_support/plugin-contract/scenario.zsh +++ b/tests/_support/plugin-contract/scenario.zsh @@ -7,9 +7,12 @@ builtin setopt extended_glob typeset_silent 0="${${(M)0:#/*}:-$PWD/$0}" typeset scenario=${1:-ordinary} typeset expected_mode=${2:-noninteractive} +typeset fixture_name=${3:-fixture} +[[ $fixture_name == [A-Za-z0-9_-]## ]] || { print -u2 -r -- "invalid fixture: $fixture_name"; exit 2 } typeset support_dir=${0:A:h} typeset repository_dir=${support_dir:h:h:h} -typeset fixture=${support_dir}/fixture.plugin.zsh +typeset fixture=${support_dir}/${fixture_name}.plugin.zsh +[[ -r $fixture ]] || { print -u2 -r -- "missing fixture: $fixture_name"; exit 2 } source "${repository_dir}/src/plugin-contract.zsh" || exit zunit_plugin_contract_prime || exit @@ -62,6 +65,22 @@ case $scenario in zunit_plugin_contract_snapshot after || exit _zunit_assert_plugin_unloaded before loaded user after ;; + reload) + # Load, unload, and load again in one clean shell. The second load must + # restore the first load's surface, and its callback must still do its + # job: presence or a zero status alone would accept an inert stub. Exact + # unload restoration is the ordinary scenario's job, so it is not + # repeated here. + source "$fixture" || exit + zunit_plugin_contract_snapshot loaded || exit + contract_fixture_plugin_unload || exit + source "$fixture" || exit + zunit_plugin_contract_snapshot reloaded || exit + _zunit_assert_plugin_restored loaded reloaded || exit + typeset -g _contract_fixture_effect= + _contract_fixture_callback || exit 23 + [[ $_contract_fixture_effect == ran ]] || exit 24 + ;; *) print -u2 -r -- "unknown scenario: $scenario" exit 2 diff --git a/tests/plugin-contract.zunit b/tests/plugin-contract.zunit index b7b9e67..bb64cc1 100644 --- a/tests/plugin-contract.zunit +++ b/tests/plugin-contract.zunit @@ -135,3 +135,38 @@ assert $state equals 0 assert "$output" is_empty } + +@test 'Fixture restores its surface and behavior after reload' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive + + assert $state equals 0 + assert "$output" is_empty +} + +@test 'Fixture reloads in an interactive shell' { + run zsh -f -i tests/_support/plugin-contract/scenario.zsh reload interactive + + assert $state equals 0 + assert "$output" is_empty +} + +@test 'Reload rejects a plugin that unload leaves as an inert stub' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive reload-broken + + assert $state equals 1 + assert "$output" contains 'function:_contract_fixture_callback' +} + +@test 'Reload rejects a restored callback that no longer has its effect' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive reload-stale + + assert $state equals 24 + assert "$output" is_empty +} + +@test 'Scenario refuses a fixture that does not exist' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive does-not-exist + + assert $state equals 2 + assert "$output" same_as 'missing fixture: does-not-exist' +}