refactor(tests): mirror the src/ module layout in tests/unit/ #957

Description

@Chemaclass

Summary

src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
tree.

Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
src/runner/exec.sh -> tests/unit/runner/exec_test.sh

Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
prefix convention.

Blocker: make test only globs one level

This must be fixed first, in its own PR, or tests silently stop running.

Makefile:67:

TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
skips it and stays green — and make test is its own CI matrix entry plus the Linux and
macOS jobs.

./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
three matrix entries would keep running everything. The two would silently disagree.

The fix is not simply "make the glob recursive"

A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
tests:

tests/acceptance/fixtures/tests_path/a_test.sh
tests/acceptance/fixtures/tests_path/other_test.sh
tests/unit/fixtures/tests/example1_test.sh
tests/unit/fixtures/tests/example2_test.sh

(156 files today, 160 with a naive recursive glob.)

So:

TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

and a guard test asserting make test's collected list equals the set of real test files —
otherwise this regresses silently the next time someone nests a directory.

Proposed layout

One directory per source module, same names:

tests/unit/<dir>/Files todayFrom
assert/12assert_* (10), directory_test, file_test
coverage/9coverage_*
runner/6runner_* (5), setup_teardown_test
cli/5watch* (3), doc_test, upgrade_test
config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
console/4console_* (3), colors_test
helper/3helpers*
system/3check_os_test, dependencies_test, io_test
util/3clock_test, math_test, str_test
reports/2reports_test, reports_json_test
doubles/1test_doubles_test
state/1state_test
main/2main_test, completions_test
learn/1learn_test
benchmark/1benchmark_test

The remainder does not mirror src/, and should not pretend to

Ten files test the project's own tooling and invariants, not a source module:

  • release_generation · release_sandbox · release_update · release_utilities ·
    release_validation — cover tools/release.sh; zero src/ references
  • package_json_test — covers package.json
  • build_test — covers build.sh
  • bash_version_test — covers the entrypoint's version gate
  • bash_compatibility_test — greps all of src/, belongs to no single module
  • redirect_error_test — behavioural, no single owner

Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
name that would be a lie.

Phasing

  1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
    keeps it honest. No files move. This PR is a prerequisite for every one below.
  2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
  3. tests/unit/project/ last, once only the remainder is left flat.

tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
rather than by source module, so the mirror argument does not apply. Revisit separately if it
ever does.

The verification that matters

The reported test total must not change. Before and after each PR:

./bashunit tests/ # Tests: N passed ... T total
make test# must collect the same set

If a file stops being collected, T drops. That single number is the safety net against the
silent-skip failure this whole issue is designed around — check it on every PR, not just the
first.

Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
bash build.sh bin -v.

Constraints

  • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
    by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
    precisely so the old glob missed them — keep both guards.
  • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
    test one directory deeper breaks those paths. Grep each file for current_dir,
    BASH_SOURCE and fixtures/ before moving it.
  • Several tests reach into src/ by path — tests/unit/build_test.sh,
    tests/unit/state_test.sh, tests/unit/completions_test.sh,
    tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
    tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
    break them — but confirm rather than assume.
  • Do not rename test functions; only file paths change.

Acceptance criteria (per PR)

  • ./bashunit tests/ reports the same total as before the change
  • make test collects the same set as ./bashunit tests/
  • No fixture is collected as a test
  • Fixture-relative paths inside moved tests still resolve
  • make sa && make lint green
  • ./bashunit --parallel --simple --strict tests/ green
  • bash build.sh bin -v prints ✅ Build verified ✅
  • No CHANGELOG entry — internal, no user-visible behaviour change

Do not

  • Do not move any test before the Makefile PR lands
  • Do not make the glob recursive without excluding fixtures/
  • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
  • Do not rename test functions

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactoringRefactoring or cleaning related

    Type

    No type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    refactor(tests): mirror the src/ module layout in tests/unit/ #957

    Description

    @Chemaclass

    Summary

    src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
    is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
    runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
    tree.

    Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

    src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
    src/runner/exec.sh -> tests/unit/runner/exec_test.sh
    

    Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
    prefix convention.

    Blocker: make test only globs one level

    This must be fixed first, in its own PR, or tests silently stop running.

    Makefile:67:

    TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

    That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
    skips it and stays green — and make test is its own CI matrix entry plus the Linux and
    macOS jobs.

    ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
    three matrix entries would keep running everything. The two would silently disagree.

    The fix is not simply "make the glob recursive"

    A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
    tests:

    tests/acceptance/fixtures/tests_path/a_test.sh
    tests/acceptance/fixtures/tests_path/other_test.sh
    tests/unit/fixtures/tests/example1_test.sh
    tests/unit/fixtures/tests/example2_test.sh
    

    (156 files today, 160 with a naive recursive glob.)

    So:

    TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

    and a guard test asserting make test's collected list equals the set of real test files —
    otherwise this regresses silently the next time someone nests a directory.

    Proposed layout

    One directory per source module, same names:

    tests/unit/<dir>/Files todayFrom
    assert/12assert_* (10), directory_test, file_test
    coverage/9coverage_*
    runner/6runner_* (5), setup_teardown_test
    cli/5watch* (3), doc_test, upgrade_test
    config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
    api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
    console/4console_* (3), colors_test
    helper/3helpers*
    system/3check_os_test, dependencies_test, io_test
    util/3clock_test, math_test, str_test
    reports/2reports_test, reports_json_test
    doubles/1test_doubles_test
    state/1state_test
    main/2main_test, completions_test
    learn/1learn_test
    benchmark/1benchmark_test

    The remainder does not mirror src/, and should not pretend to

    Ten files test the project's own tooling and invariants, not a source module:

    • release_generation · release_sandbox · release_update · release_utilities ·
      release_validation — cover tools/release.sh; zero src/ references
    • package_json_test — covers package.json
    • build_test — covers build.sh
    • bash_version_test — covers the entrypoint's version gate
    • bash_compatibility_test — greps all of src/, belongs to no single module
    • redirect_error_test — behavioural, no single owner

    Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
    name that would be a lie.

    Phasing

    1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
      keeps it honest. No files move. This PR is a prerequisite for every one below.
    2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
    3. tests/unit/project/ last, once only the remainder is left flat.

    tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
    rather than by source module, so the mirror argument does not apply. Revisit separately if it
    ever does.

    The verification that matters

    The reported test total must not change. Before and after each PR:

    ./bashunit tests/ # Tests: N passed ... T total
    make test# must collect the same set

    If a file stops being collected, T drops. That single number is the safety net against the
    silent-skip failure this whole issue is designed around — check it on every PR, not just the
    first.

    Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
    bash build.sh bin -v.

    Constraints

    • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
      by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
      precisely so the old glob missed them — keep both guards.
    • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
      test one directory deeper breaks those paths. Grep each file for current_dir,
      BASH_SOURCE and fixtures/ before moving it.
    • Several tests reach into src/ by path — tests/unit/build_test.sh,
      tests/unit/state_test.sh, tests/unit/completions_test.sh,
      tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
      tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
      break them — but confirm rather than assume.
    • Do not rename test functions; only file paths change.

    Acceptance criteria (per PR)

    • ./bashunit tests/ reports the same total as before the change
    • make test collects the same set as ./bashunit tests/
    • No fixture is collected as a test
    • Fixture-relative paths inside moved tests still resolve
    • make sa && make lint green
    • ./bashunit --parallel --simple --strict tests/ green
    • bash build.sh bin -v prints ✅ Build verified ✅
    • No CHANGELOG entry — internal, no user-visible behaviour change

    Do not

    • Do not move any test before the Makefile PR lands
    • Do not make the glob recursive without excluding fixtures/
    • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
    • Do not rename test functions

    Metadata

    Metadata

    Assignees

    No one assigned

      Labels

      refactoringRefactoring or cleaning related

      Type

      No type

      Projects

      • Status
        Done

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      refactor(tests): mirror the src/ module layout in tests/unit/ #957

      Description

      @Chemaclass

      Summary

      src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
      is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
      runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
      tree.

      Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

      src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
      src/runner/exec.sh -> tests/unit/runner/exec_test.sh
      

      Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
      prefix convention.

      Blocker: make test only globs one level

      This must be fixed first, in its own PR, or tests silently stop running.

      Makefile:67:

      TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

      That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
      skips it and stays green — and make test is its own CI matrix entry plus the Linux and
      macOS jobs.

      ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
      three matrix entries would keep running everything. The two would silently disagree.

      The fix is not simply "make the glob recursive"

      A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
      tests:

      tests/acceptance/fixtures/tests_path/a_test.sh
      tests/acceptance/fixtures/tests_path/other_test.sh
      tests/unit/fixtures/tests/example1_test.sh
      tests/unit/fixtures/tests/example2_test.sh
      

      (156 files today, 160 with a naive recursive glob.)

      So:

      TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

      and a guard test asserting make test's collected list equals the set of real test files —
      otherwise this regresses silently the next time someone nests a directory.

      Proposed layout

      One directory per source module, same names:

      tests/unit/<dir>/Files todayFrom
      assert/12assert_* (10), directory_test, file_test
      coverage/9coverage_*
      runner/6runner_* (5), setup_teardown_test
      cli/5watch* (3), doc_test, upgrade_test
      config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
      api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
      console/4console_* (3), colors_test
      helper/3helpers*
      system/3check_os_test, dependencies_test, io_test
      util/3clock_test, math_test, str_test
      reports/2reports_test, reports_json_test
      doubles/1test_doubles_test
      state/1state_test
      main/2main_test, completions_test
      learn/1learn_test
      benchmark/1benchmark_test

      The remainder does not mirror src/, and should not pretend to

      Ten files test the project's own tooling and invariants, not a source module:

      • release_generation · release_sandbox · release_update · release_utilities ·
        release_validation — cover tools/release.sh; zero src/ references
      • package_json_test — covers package.json
      • build_test — covers build.sh
      • bash_version_test — covers the entrypoint's version gate
      • bash_compatibility_test — greps all of src/, belongs to no single module
      • redirect_error_test — behavioural, no single owner

      Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
      name that would be a lie.

      Phasing

      1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
        keeps it honest. No files move. This PR is a prerequisite for every one below.
      2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
      3. tests/unit/project/ last, once only the remainder is left flat.

      tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
      rather than by source module, so the mirror argument does not apply. Revisit separately if it
      ever does.

      The verification that matters

      The reported test total must not change. Before and after each PR:

      ./bashunit tests/ # Tests: N passed ... T total
      make test# must collect the same set

      If a file stops being collected, T drops. That single number is the safety net against the
      silent-skip failure this whole issue is designed around — check it on every PR, not just the
      first.

      Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
      bash build.sh bin -v.

      Constraints

      • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
        by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
        precisely so the old glob missed them — keep both guards.
      • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
        test one directory deeper breaks those paths. Grep each file for current_dir,
        BASH_SOURCE and fixtures/ before moving it.
      • Several tests reach into src/ by path — tests/unit/build_test.sh,
        tests/unit/state_test.sh, tests/unit/completions_test.sh,
        tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
        tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
        break them — but confirm rather than assume.
      • Do not rename test functions; only file paths change.

      Acceptance criteria (per PR)

      • ./bashunit tests/ reports the same total as before the change
      • make test collects the same set as ./bashunit tests/
      • No fixture is collected as a test
      • Fixture-relative paths inside moved tests still resolve
      • make sa && make lint green
      • ./bashunit --parallel --simple --strict tests/ green
      • bash build.sh bin -v prints ✅ Build verified ✅
      • No CHANGELOG entry — internal, no user-visible behaviour change

      Do not

      • Do not move any test before the Makefile PR lands
      • Do not make the glob recursive without excluding fixtures/
      • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
      • Do not rename test functions

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        refactoringRefactoring or cleaning related

        Type

        No type

        Projects

        • Status
          Done

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        refactor(tests): mirror the src/ module layout in tests/unit/ #957

        Description

        @Chemaclass

        Summary

        src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
        is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
        runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
        tree.

        Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

        src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
        src/runner/exec.sh -> tests/unit/runner/exec_test.sh
        

        Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
        prefix convention.

        Blocker: make test only globs one level

        This must be fixed first, in its own PR, or tests silently stop running.

        Makefile:67:

        TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

        That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
        skips it and stays green — and make test is its own CI matrix entry plus the Linux and
        macOS jobs.

        ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
        three matrix entries would keep running everything. The two would silently disagree.

        The fix is not simply "make the glob recursive"

        A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
        tests:

        tests/acceptance/fixtures/tests_path/a_test.sh
        tests/acceptance/fixtures/tests_path/other_test.sh
        tests/unit/fixtures/tests/example1_test.sh
        tests/unit/fixtures/tests/example2_test.sh
        

        (156 files today, 160 with a naive recursive glob.)

        So:

        TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

        and a guard test asserting make test's collected list equals the set of real test files —
        otherwise this regresses silently the next time someone nests a directory.

        Proposed layout

        One directory per source module, same names:

        tests/unit/<dir>/Files todayFrom
        assert/12assert_* (10), directory_test, file_test
        coverage/9coverage_*
        runner/6runner_* (5), setup_teardown_test
        cli/5watch* (3), doc_test, upgrade_test
        config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
        api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
        console/4console_* (3), colors_test
        helper/3helpers*
        system/3check_os_test, dependencies_test, io_test
        util/3clock_test, math_test, str_test
        reports/2reports_test, reports_json_test
        doubles/1test_doubles_test
        state/1state_test
        main/2main_test, completions_test
        learn/1learn_test
        benchmark/1benchmark_test

        The remainder does not mirror src/, and should not pretend to

        Ten files test the project's own tooling and invariants, not a source module:

        • release_generation · release_sandbox · release_update · release_utilities ·
          release_validation — cover tools/release.sh; zero src/ references
        • package_json_test — covers package.json
        • build_test — covers build.sh
        • bash_version_test — covers the entrypoint's version gate
        • bash_compatibility_test — greps all of src/, belongs to no single module
        • redirect_error_test — behavioural, no single owner

        Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
        name that would be a lie.

        Phasing

        1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
          keeps it honest. No files move. This PR is a prerequisite for every one below.
        2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
        3. tests/unit/project/ last, once only the remainder is left flat.

        tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
        rather than by source module, so the mirror argument does not apply. Revisit separately if it
        ever does.

        The verification that matters

        The reported test total must not change. Before and after each PR:

        ./bashunit tests/ # Tests: N passed ... T total
        make test# must collect the same set

        If a file stops being collected, T drops. That single number is the safety net against the
        silent-skip failure this whole issue is designed around — check it on every PR, not just the
        first.

        Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
        bash build.sh bin -v.

        Constraints

        • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
          by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
          precisely so the old glob missed them — keep both guards.
        • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
          test one directory deeper breaks those paths. Grep each file for current_dir,
          BASH_SOURCE and fixtures/ before moving it.
        • Several tests reach into src/ by path — tests/unit/build_test.sh,
          tests/unit/state_test.sh, tests/unit/completions_test.sh,
          tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
          tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
          break them — but confirm rather than assume.
        • Do not rename test functions; only file paths change.

        Acceptance criteria (per PR)

        • ./bashunit tests/ reports the same total as before the change
        • make test collects the same set as ./bashunit tests/
        • No fixture is collected as a test
        • Fixture-relative paths inside moved tests still resolve
        • make sa && make lint green
        • ./bashunit --parallel --simple --strict tests/ green
        • bash build.sh bin -v prints ✅ Build verified ✅
        • No CHANGELOG entry — internal, no user-visible behaviour change

        Do not

        • Do not move any test before the Makefile PR lands
        • Do not make the glob recursive without excluding fixtures/
        • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
        • Do not rename test functions

        Metadata

        Metadata

        Assignees

        No one assigned

          Labels

          refactoringRefactoring or cleaning related

          Type

          No type

          Projects

          • Status
            Done

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          refactor(tests): mirror the src/ module layout in tests/unit/ #957

          Description

          @Chemaclass

          Summary

          src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
          is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
          runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
          tree.

          Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

          src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
          src/runner/exec.sh -> tests/unit/runner/exec_test.sh
          

          Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
          prefix convention.

          Blocker: make test only globs one level

          This must be fixed first, in its own PR, or tests silently stop running.

          Makefile:67:

          TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

          That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
          skips it and stays green — and make test is its own CI matrix entry plus the Linux and
          macOS jobs.

          ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
          three matrix entries would keep running everything. The two would silently disagree.

          The fix is not simply "make the glob recursive"

          A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
          tests:

          tests/acceptance/fixtures/tests_path/a_test.sh
          tests/acceptance/fixtures/tests_path/other_test.sh
          tests/unit/fixtures/tests/example1_test.sh
          tests/unit/fixtures/tests/example2_test.sh
          

          (156 files today, 160 with a naive recursive glob.)

          So:

          TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

          and a guard test asserting make test's collected list equals the set of real test files —
          otherwise this regresses silently the next time someone nests a directory.

          Proposed layout

          One directory per source module, same names:

          tests/unit/<dir>/Files todayFrom
          assert/12assert_* (10), directory_test, file_test
          coverage/9coverage_*
          runner/6runner_* (5), setup_teardown_test
          cli/5watch* (3), doc_test, upgrade_test
          config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
          api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
          console/4console_* (3), colors_test
          helper/3helpers*
          system/3check_os_test, dependencies_test, io_test
          util/3clock_test, math_test, str_test
          reports/2reports_test, reports_json_test
          doubles/1test_doubles_test
          state/1state_test
          main/2main_test, completions_test
          learn/1learn_test
          benchmark/1benchmark_test

          The remainder does not mirror src/, and should not pretend to

          Ten files test the project's own tooling and invariants, not a source module:

          • release_generation · release_sandbox · release_update · release_utilities ·
            release_validation — cover tools/release.sh; zero src/ references
          • package_json_test — covers package.json
          • build_test — covers build.sh
          • bash_version_test — covers the entrypoint's version gate
          • bash_compatibility_test — greps all of src/, belongs to no single module
          • redirect_error_test — behavioural, no single owner

          Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
          name that would be a lie.

          Phasing

          1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
            keeps it honest. No files move. This PR is a prerequisite for every one below.
          2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
          3. tests/unit/project/ last, once only the remainder is left flat.

          tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
          rather than by source module, so the mirror argument does not apply. Revisit separately if it
          ever does.

          The verification that matters

          The reported test total must not change. Before and after each PR:

          ./bashunit tests/ # Tests: N passed ... T total
          make test# must collect the same set

          If a file stops being collected, T drops. That single number is the safety net against the
          silent-skip failure this whole issue is designed around — check it on every PR, not just the
          first.

          Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
          bash build.sh bin -v.

          Constraints

          • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
            by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
            precisely so the old glob missed them — keep both guards.
          • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
            test one directory deeper breaks those paths. Grep each file for current_dir,
            BASH_SOURCE and fixtures/ before moving it.
          • Several tests reach into src/ by path — tests/unit/build_test.sh,
            tests/unit/state_test.sh, tests/unit/completions_test.sh,
            tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
            tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
            break them — but confirm rather than assume.
          • Do not rename test functions; only file paths change.

          Acceptance criteria (per PR)

          • ./bashunit tests/ reports the same total as before the change
          • make test collects the same set as ./bashunit tests/
          • No fixture is collected as a test
          • Fixture-relative paths inside moved tests still resolve
          • make sa && make lint green
          • ./bashunit --parallel --simple --strict tests/ green
          • bash build.sh bin -v prints ✅ Build verified ✅
          • No CHANGELOG entry — internal, no user-visible behaviour change

          Do not

          • Do not move any test before the Makefile PR lands
          • Do not make the glob recursive without excluding fixtures/
          • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
          • Do not rename test functions

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            refactoringRefactoring or cleaning related

            Type

            No type

            Projects

            • Status
              Done

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            refactor(tests): mirror the src/ module layout in tests/unit/ #957

            Description

            @Chemaclass

            Summary

            src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
            is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
            runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
            tree.

            Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

            src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
            src/runner/exec.sh -> tests/unit/runner/exec_test.sh
            

            Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
            prefix convention.

            Blocker: make test only globs one level

            This must be fixed first, in its own PR, or tests silently stop running.

            Makefile:67:

            TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

            That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
            skips it and stays green — and make test is its own CI matrix entry plus the Linux and
            macOS jobs.

            ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
            three matrix entries would keep running everything. The two would silently disagree.

            The fix is not simply "make the glob recursive"

            A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
            tests:

            tests/acceptance/fixtures/tests_path/a_test.sh
            tests/acceptance/fixtures/tests_path/other_test.sh
            tests/unit/fixtures/tests/example1_test.sh
            tests/unit/fixtures/tests/example2_test.sh
            

            (156 files today, 160 with a naive recursive glob.)

            So:

            TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

            and a guard test asserting make test's collected list equals the set of real test files —
            otherwise this regresses silently the next time someone nests a directory.

            Proposed layout

            One directory per source module, same names:

            tests/unit/<dir>/Files todayFrom
            assert/12assert_* (10), directory_test, file_test
            coverage/9coverage_*
            runner/6runner_* (5), setup_teardown_test
            cli/5watch* (3), doc_test, upgrade_test
            config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
            api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
            console/4console_* (3), colors_test
            helper/3helpers*
            system/3check_os_test, dependencies_test, io_test
            util/3clock_test, math_test, str_test
            reports/2reports_test, reports_json_test
            doubles/1test_doubles_test
            state/1state_test
            main/2main_test, completions_test
            learn/1learn_test
            benchmark/1benchmark_test

            The remainder does not mirror src/, and should not pretend to

            Ten files test the project's own tooling and invariants, not a source module:

            • release_generation · release_sandbox · release_update · release_utilities ·
              release_validation — cover tools/release.sh; zero src/ references
            • package_json_test — covers package.json
            • build_test — covers build.sh
            • bash_version_test — covers the entrypoint's version gate
            • bash_compatibility_test — greps all of src/, belongs to no single module
            • redirect_error_test — behavioural, no single owner

            Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
            name that would be a lie.

            Phasing

            1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
              keeps it honest. No files move. This PR is a prerequisite for every one below.
            2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
            3. tests/unit/project/ last, once only the remainder is left flat.

            tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
            rather than by source module, so the mirror argument does not apply. Revisit separately if it
            ever does.

            The verification that matters

            The reported test total must not change. Before and after each PR:

            ./bashunit tests/ # Tests: N passed ... T total
            make test# must collect the same set

            If a file stops being collected, T drops. That single number is the safety net against the
            silent-skip failure this whole issue is designed around — check it on every PR, not just the
            first.

            Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
            bash build.sh bin -v.

            Constraints

            • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
              by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
              precisely so the old glob missed them — keep both guards.
            • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
              test one directory deeper breaks those paths. Grep each file for current_dir,
              BASH_SOURCE and fixtures/ before moving it.
            • Several tests reach into src/ by path — tests/unit/build_test.sh,
              tests/unit/state_test.sh, tests/unit/completions_test.sh,
              tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
              tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
              break them — but confirm rather than assume.
            • Do not rename test functions; only file paths change.

            Acceptance criteria (per PR)

            • ./bashunit tests/ reports the same total as before the change
            • make test collects the same set as ./bashunit tests/
            • No fixture is collected as a test
            • Fixture-relative paths inside moved tests still resolve
            • make sa && make lint green
            • ./bashunit --parallel --simple --strict tests/ green
            • bash build.sh bin -v prints ✅ Build verified ✅
            • No CHANGELOG entry — internal, no user-visible behaviour change

            Do not

            • Do not move any test before the Makefile PR lands
            • Do not make the glob recursive without excluding fixtures/
            • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
            • Do not rename test functions

            Metadata

            Metadata

            Assignees

            No one assigned

              Labels

              refactoringRefactoring or cleaning related

              Type

              No type

              Projects

              • Status
                Done

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              refactor(tests): mirror the src/ module layout in tests/unit/ #957

              Description

              @Chemaclass

              Summary

              src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
              is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
              runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
              tree.

              Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

              src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
              src/runner/exec.sh -> tests/unit/runner/exec_test.sh
              

              Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
              prefix convention.

              Blocker: make test only globs one level

              This must be fixed first, in its own PR, or tests silently stop running.

              Makefile:67:

              TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

              That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
              skips it and stays green — and make test is its own CI matrix entry plus the Linux and
              macOS jobs.

              ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
              three matrix entries would keep running everything. The two would silently disagree.

              The fix is not simply "make the glob recursive"

              A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
              tests:

              tests/acceptance/fixtures/tests_path/a_test.sh
              tests/acceptance/fixtures/tests_path/other_test.sh
              tests/unit/fixtures/tests/example1_test.sh
              tests/unit/fixtures/tests/example2_test.sh
              

              (156 files today, 160 with a naive recursive glob.)

              So:

              TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

              and a guard test asserting make test's collected list equals the set of real test files —
              otherwise this regresses silently the next time someone nests a directory.

              Proposed layout

              One directory per source module, same names:

              tests/unit/<dir>/Files todayFrom
              assert/12assert_* (10), directory_test, file_test
              coverage/9coverage_*
              runner/6runner_* (5), setup_teardown_test
              cli/5watch* (3), doc_test, upgrade_test
              config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
              api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
              console/4console_* (3), colors_test
              helper/3helpers*
              system/3check_os_test, dependencies_test, io_test
              util/3clock_test, math_test, str_test
              reports/2reports_test, reports_json_test
              doubles/1test_doubles_test
              state/1state_test
              main/2main_test, completions_test
              learn/1learn_test
              benchmark/1benchmark_test

              The remainder does not mirror src/, and should not pretend to

              Ten files test the project's own tooling and invariants, not a source module:

              • release_generation · release_sandbox · release_update · release_utilities ·
                release_validation — cover tools/release.sh; zero src/ references
              • package_json_test — covers package.json
              • build_test — covers build.sh
              • bash_version_test — covers the entrypoint's version gate
              • bash_compatibility_test — greps all of src/, belongs to no single module
              • redirect_error_test — behavioural, no single owner

              Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
              name that would be a lie.

              Phasing

              1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
                keeps it honest. No files move. This PR is a prerequisite for every one below.
              2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
              3. tests/unit/project/ last, once only the remainder is left flat.

              tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
              rather than by source module, so the mirror argument does not apply. Revisit separately if it
              ever does.

              The verification that matters

              The reported test total must not change. Before and after each PR:

              ./bashunit tests/ # Tests: N passed ... T total
              make test# must collect the same set

              If a file stops being collected, T drops. That single number is the safety net against the
              silent-skip failure this whole issue is designed around — check it on every PR, not just the
              first.

              Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
              bash build.sh bin -v.

              Constraints

              • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
                by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
                precisely so the old glob missed them — keep both guards.
              • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
                test one directory deeper breaks those paths. Grep each file for current_dir,
                BASH_SOURCE and fixtures/ before moving it.
              • Several tests reach into src/ by path — tests/unit/build_test.sh,
                tests/unit/state_test.sh, tests/unit/completions_test.sh,
                tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
                tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
                break them — but confirm rather than assume.
              • Do not rename test functions; only file paths change.

              Acceptance criteria (per PR)

              • ./bashunit tests/ reports the same total as before the change
              • make test collects the same set as ./bashunit tests/
              • No fixture is collected as a test
              • Fixture-relative paths inside moved tests still resolve
              • make sa && make lint green
              • ./bashunit --parallel --simple --strict tests/ green
              • bash build.sh bin -v prints ✅ Build verified ✅
              • No CHANGELOG entry — internal, no user-visible behaviour change

              Do not

              • Do not move any test before the Makefile PR lands
              • Do not make the glob recursive without excluding fixtures/
              • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
              • Do not rename test functions

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                refactoringRefactoring or cleaning related

                Type

                No type

                Projects

                • Status
                  Done

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                refactor(tests): mirror the src/ module layout in tests/unit/ #957

                Description

                @Chemaclass

                Summary

                src/ is now 17 modules with no loose files (#931, #940, #948, #949, ADR-011). tests/unit/
                is still 71 flat filesassert_advanced_test.sh, coverage_engine_test.sh,
                runner_exec_test.sh, … — with the module encoded in a filename prefix rather than in the
                tree.

                Mirror the source layout, the way PHPUnit projects mirror src/ into tests/:

                src/coverage/engine.sh -> tests/unit/coverage/engine_test.sh
                src/runner/exec.sh -> tests/unit/runner/exec_test.sh
                

                Finding the tests for a module becomes ls tests/unit/<module>/ instead of remembering a
                prefix convention.

                Blocker: make test only globs one level

                This must be fixed first, in its own PR, or tests silently stop running.

                Makefile:67:

                TEST_SCRIPTS = $(wildcard$(TEST_SCRIPTS_DIR)/*/*[tT]est.sh)

                That matches tests/<dir>/<name>_test.sh and nothing deeper. Nest a test and make test
                skips it and stays green — and make test is its own CI matrix entry plus the Linux and
                macOS jobs.

                ./bashunit tests/ already recurses (verified: it finds unit/sub/deep_test.sh), so the other
                three matrix entries would keep running everything. The two would silently disagree.

                The fix is not simply "make the glob recursive"

                A naive recursive glob sweeps in four fixture files that are inputs to other tests, not
                tests:

                tests/acceptance/fixtures/tests_path/a_test.sh
                tests/acceptance/fixtures/tests_path/other_test.sh
                tests/unit/fixtures/tests/example1_test.sh
                tests/unit/fixtures/tests/example2_test.sh
                

                (156 files today, 160 with a naive recursive glob.)

                So:

                TEST_SCRIPTS = $(shell find $(TEST_SCRIPTS_DIR) -name '*[tT]est.sh' -not -path '*/fixtures/*')

                and a guard test asserting make test's collected list equals the set of real test files —
                otherwise this regresses silently the next time someone nests a directory.

                Proposed layout

                One directory per source module, same names:

                tests/unit/<dir>/Files todayFrom
                assert/12assert_* (10), directory_test, file_test
                coverage/9coverage_*
                runner/6runner_* (5), setup_teardown_test
                cli/5watch* (3), doc_test, upgrade_test
                config/4env_test, env_deprecated_aliases_test, parallel_test, rerun_test
                api/4globals_test, skip_todo_test, test_title_test, custom_assertions_test
                console/4console_* (3), colors_test
                helper/3helpers*
                system/3check_os_test, dependencies_test, io_test
                util/3clock_test, math_test, str_test
                reports/2reports_test, reports_json_test
                doubles/1test_doubles_test
                state/1state_test
                main/2main_test, completions_test
                learn/1learn_test
                benchmark/1benchmark_test

                The remainder does not mirror src/, and should not pretend to

                Ten files test the project's own tooling and invariants, not a source module:

                • release_generation · release_sandbox · release_update · release_utilities ·
                  release_validation — cover tools/release.sh; zero src/ references
                • package_json_test — covers package.json
                • build_test — covers build.sh
                • bash_version_test — covers the entrypoint's version gate
                • bash_compatibility_test — greps all of src/, belongs to no single module
                • redirect_error_test — behavioural, no single owner

                Proposal: tests/unit/project/. Naming it something honest beats forcing it under a module
                name that would be a lie.

                Phasing

                1. Makefile + guard test. Recursive collection excluding fixtures/, plus the test that
                  keeps it honest. No files move. This PR is a prerequisite for every one below.
                2. One PR per module directory, largest first (assert/, coverage/, runner/, …).
                3. tests/unit/project/ last, once only the remainder is left flat.

                tests/functional/ and tests/acceptance/ stay flat for now — they are organised by scenario
                rather than by source module, so the mirror argument does not apply. Revisit separately if it
                ever does.

                The verification that matters

                The reported test total must not change. Before and after each PR:

                ./bashunit tests/ # Tests: N passed ... T total
                make test# must collect the same set

                If a file stops being collected, T drops. That single number is the safety net against the
                silent-skip failure this whole issue is designed around — check it on every PR, not just the
                first.

                Also per PR: make sa · make lint · ./bashunit --parallel --simple --strict tests/ ·
                bash build.sh bin -v.

                Constraints

                • Fixtures must never be collected as tests. They live under fixtures/ and are excluded
                  by path. Fixture files are also named test_*.sh (prefix) rather than *_test.sh (suffix)
                  precisely so the old glob missed them — keep both guards.
                • Tests reference fixtures by relative path ($(bashunit::current_dir)/fixtures/...). Moving a
                  test one directory deeper breaks those paths. Grep each file for current_dir,
                  BASH_SOURCE and fixtures/ before moving it.
                • Several tests reach into src/ by path — tests/unit/build_test.sh,
                  tests/unit/state_test.sh, tests/unit/completions_test.sh,
                  tests/unit/env_deprecated_aliases_test.sh, tests/unit/helpers_test.sh,
                  tests/unit/check_os_test.sh. Those paths are repo-relative, so moving the test does not
                  break them — but confirm rather than assume.
                • Do not rename test functions; only file paths change.

                Acceptance criteria (per PR)

                • ./bashunit tests/ reports the same total as before the change
                • make test collects the same set as ./bashunit tests/
                • No fixture is collected as a test
                • Fixture-relative paths inside moved tests still resolve
                • make sa && make lint green
                • ./bashunit --parallel --simple --strict tests/ green
                • bash build.sh bin -v prints ✅ Build verified ✅
                • No CHANGELOG entry — internal, no user-visible behaviour change

                Do not

                • Do not move any test before the Makefile PR lands
                • Do not make the glob recursive without excluding fixtures/
                • Do not create tests/unit/<module>/<submodule>/ — one level of mirroring is the goal
                • Do not rename test functions

                Metadata

                Metadata

                Assignees

                No one assigned

                  Labels

                  refactoringRefactoring or cleaning related

                  Type

                  No type

                  Projects

                  • Status
                    Done

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions