Skip to content

Commit fa97cfd

Browse files
authored
Merge pull request #590 from TypedDevs/feat/assert-have-been-called-nth-with
feat(doubles): add assert_have_been_called_nth_with assertion
2 parents 857c64b + cb6985d commit fa97cfd

7 files changed

Lines changed: 220 additions & 3 deletions

File tree

.claude/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,14 @@ Complete end-to-end workflow from issue to PR:
241241
- Use speculative/unproven patterns
242242
- Commit without tests passing
243243
- Batch unrelated changes in one PR
244+
- Create a PR without using the `/pr` skill (even if user says "create pr")
244245

245246
### Always:
246247
- Write tests before implementation
247248
- Use existing patterns from `tests/**` and `src/**`
248249
- Minimal code in GREEN phase
249250
- Keep tests passing during REFACTOR
251+
- Update CHANGELOG.md before creating a PR (for user-facing changes)
250252
- Run quality checks before committing
251253
- Update CHANGELOG.md for user-visible changes
252254
- Maintain Bash 3.0+ compatibility

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
3+
"includeCoAuthoredBy": false
4+
}

.claude/skills/pr/SKILL.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
name: pr
3+
description: Push branch and create a GitHub PR with concise, issue-linked description
4+
user-invocable: true
5+
argument-hint: "[#issue]"
6+
disable-model-invocation: true
7+
allowed-tools: Bash, Read, Grep, Glob
8+
---
9+
10+
# Create Pull Request
11+
12+
Push branch and create a PR with a concise, issue-linked description.
13+
14+
> **IMPORTANT:** This skill MUST be used for ALL pull request creation — even when the user says "create pr" without `/pr`. Never create a PR without following these steps.
15+
16+
## Current Branch Context
17+
- Branch: !`git branch --show-current`
18+
- Commits: !`git log main..HEAD --oneline 2>/dev/null`
19+
- Changed files: !`git diff main..HEAD --stat 2>/dev/null`
20+
21+
## Arguments
22+
- `$ARGUMENTS` - Issue reference (optional, e.g., `#42` or `42`). If provided, the PR will be linked to this issue.
23+
24+
## Instructions
25+
26+
1. **Review the branch context above** — the commits and changed files are already loaded.
27+
28+
2. **MANDATORY: Update CHANGELOG.md** — Read `CHANGELOG.md` and check the `## Unreleased` section. If the changes from this branch are NOT already listed there, you MUST update it before proceeding. **Do NOT skip this step. Do NOT proceed to push without verifying.**
29+
- Add entries under the appropriate subsection (`### Added`, `### Changed`, `### Fixed`, etc.)
30+
- Reference issue numbers where applicable (e.g., `(Issue #123)`)
31+
- Commit the update:
32+
```bash
33+
git add CHANGELOG.md && git commit -m "docs: update changelog"
34+
```
35+
36+
3. **Push branch**:
37+
```bash
38+
git push -u origin HEAD
39+
```
40+
- The `pre-push` git hook automatically runs the full test suite (BE & FE in parallel).
41+
- If the hook fails, read the output, fix the issue, commit the fix, and retry the push. Do NOT use `--no-verify` to bypass.
42+
43+
4. **Generate PR title**:
44+
- If `$ARGUMENTS` contains an issue number, fetch the issue title:
45+
```bash
46+
gh issue view <number> --json title -q '.title'
47+
```
48+
- PR title format: `<type>(<scope>): <short description>` (conventional commit style, under 70 chars)
49+
- Derive the type from the branch prefix (`feat/` → feat, `fix/` → fix, `docs/` → docs)
50+
51+
5. **Create PR** using the template from `.github/PULL_REQUEST_TEMPLATE.md`:
52+
```bash
53+
gh pr create --title "<title>" --assignee @me --label "<label>" --body "$(cat <<'EOF'
54+
## 🤔 Background
55+
56+
Related #<issue-number>
57+
58+
<1-2 sentences: motivation and context for the changes>
59+
60+
## 💡 Changes
61+
62+
- <bullet 1: what changed and why>
63+
- <bullet 2>
64+
- <bullet 3> (optional)
65+
- <bullet 4> (optional)
66+
EOF
67+
)"
68+
```
69+
70+
**MANDATORY:** Always follow the PR template structure (`## 🤔 Background` + `## 💡 Changes`). Never use a different format.
71+
72+
**Assignee:** Always assign to `@me` (the PR creator).
73+
74+
**Labels:** Add the single most relevant label based on the branch prefix and change context:
75+
- `bug` — branch starts with `fix/` and addresses a defect
76+
- `enhancement` — branch starts with `feat/` or adds new functionality
77+
- `documentation` — branch starts with `docs/` or only changes docs
78+
- `refactor` — code restructuring with no behavior change
79+
- `ui` — visual/frontend-only changes
80+
- `investigation` — spikes, research, or exploratory work
81+
82+
**Body guidelines:**
83+
- **Background**: Link the issue with `Related #<number>`, then 1-2 sentences of context. **NEVER use `Closes` or `Fixes`.**
84+
- **Changes**: 2-4 short bullet points. Focus on *what* and *why*, not implementation details.
85+
- **No file lists, no class names, no code snippets** in the body.
86+
- Keep the entire body under 15 lines.
87+
88+
6. **Move issue to "In Review"** in GitHub Project (if issue number provided):
89+
```bash
90+
# Read project config from .claude/github-project.json
91+
ITEM_ID=$(gh project item-list PROJECT_NUMBER --owner OWNER --format json \
92+
| jq -r '.items[] | select(.content.number == ISSUE_NUMBER) | .id')
93+
94+
gh project item-edit \
95+
--id "$ITEM_ID" \
96+
--project-id "PROJECT_ID" \
97+
--field-id "STATUS_FIELD_ID" \
98+
--single-select-option-id "IN_REVIEW_OPTION_ID"
99+
```
100+
101+
**Note:** Requires `project` scope. Run `gh auth refresh -s project` if needed.
102+
103+
7. **Report the PR URL** to the user.
104+
105+
## Example Usage
106+
107+
```
108+
/pr
109+
/pr #42
110+
/pr 15
111+
```

.github/workflows/tests.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,26 @@ jobs:
3434
run: make test
3535

3636
windows:
37-
name: "On windows (${{ matrix.test_chunk }})"
37+
name: "On windows (${{ matrix.name }})"
3838
timeout-minutes: 10
3939
runs-on: windows-latest
4040
strategy:
4141
matrix:
42-
test_chunk: [acceptance, functional, unit]
42+
include:
43+
- name: "acceptance a-l"
44+
test_path: "tests/acceptance/bashunit_[a-l]*_test.sh"
45+
- name: "acceptance m-z"
46+
test_path: "tests/acceptance/bashunit_[m-z]*_test.sh tests/acceptance/[i-p]*_test.sh"
47+
- name: functional
48+
test_path: "tests/functional/*_test.sh"
49+
- name: "unit a-b"
50+
test_path: "tests/unit/[a-b]*_test.sh"
51+
- name: "unit c"
52+
test_path: "tests/unit/c*_test.sh"
53+
- name: "unit d-p"
54+
test_path: "tests/unit/[d-p]*_test.sh"
55+
- name: "unit r-z"
56+
test_path: "tests/unit/[r-z]*_test.sh"
4357
fail-fast: false
4458
steps:
4559
- name: Checkout code
@@ -50,7 +64,7 @@ jobs:
5064
- name: Run tests
5165
shell: bash
5266
run: |
53-
./bashunit --parallel tests/${{ matrix.test_chunk }}/*_test.sh
67+
./bashunit --parallel ${{ matrix.test_path }}
5468
5569
alpine:
5670
name: "On alpine-latest"

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Added
6+
- Add `assert_have_been_called_nth_with` assertion for verifying arguments on the Nth invocation of a spy (Issue #172)
7+
8+
### Changed
9+
- Split Windows CI test jobs into parallel chunks to avoid timeouts
10+
511
## [0.33.0](https://github.com/TypedDevs/bashunit/compare/0.32.0...0.33.0) - 2026-02-15
612

713
### Changed

src/test_doubles.sh

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,49 @@ function assert_have_been_called_times() {
156156
bashunit::state::add_assertions_passed
157157
}
158158

159+
function assert_have_been_called_nth_with() {
160+
local nth=$1
161+
local command=$2
162+
shift 2
163+
local expected="$*"
164+
165+
local variable
166+
variable="$(bashunit::helper::normalize_variable_name "$command")"
167+
local times_file_var="${variable}_times_file"
168+
local file_var="${variable}_params_file"
169+
local label
170+
label="$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")"
171+
172+
local times=0
173+
if [ -f "${!times_file_var-}" ]; then
174+
times=$(cat "${!times_file_var}" 2>/dev/null || echo 0)
175+
fi
176+
177+
if [ "$nth" -gt "$times" ]; then
178+
bashunit::state::add_assertions_failed
179+
bashunit::console_results::print_failed_test "${label}" \
180+
"expected call" "at index ${nth} but" "only called ${times} times"
181+
return
182+
fi
183+
184+
local line=""
185+
if [ -f "${!file_var-}" ]; then
186+
line=$(sed -n "${nth}p" "${!file_var}" 2>/dev/null || true)
187+
fi
188+
189+
local raw
190+
IFS=$'\x1e' read -r raw _ <<<"$line" || true
191+
192+
if [ "$expected" != "$raw" ]; then
193+
bashunit::state::add_assertions_failed
194+
bashunit::console_results::print_failed_test "${label}" \
195+
"$expected" "but got " "$raw"
196+
return
197+
fi
198+
199+
bashunit::state::add_assertions_passed
200+
}
201+
159202
function assert_not_called() {
160203
local command=$1
161204
local label="${2:-$(bashunit::helper::normalize_test_function_name "${FUNCNAME[1]}")}"

tests/unit/test_doubles_test.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,40 @@ function test_spy_with_pipe_in_arguments() {
177177

178178
assert_have_been_called_with grep '-E foo|bar'
179179
}
180+
181+
function test_successful_spy_nth_called_with() {
182+
bashunit::spy ps
183+
184+
ps first_a first_b
185+
ps second
186+
ps third
187+
188+
assert_have_been_called_nth_with 1 ps "first_a first_b"
189+
assert_have_been_called_nth_with 2 ps "second"
190+
assert_have_been_called_nth_with 3 ps "third"
191+
}
192+
193+
function test_unsuccessful_spy_nth_called_with() {
194+
bashunit::spy ps
195+
196+
ps first
197+
ps second
198+
199+
assert_same \
200+
"$(bashunit::console_results::print_failed_test \
201+
"Unsuccessful spy nth called with" \
202+
"wrong" "but got " "first")" \
203+
"$(assert_have_been_called_nth_with 1 ps "wrong")"
204+
}
205+
206+
function test_unsuccessful_spy_nth_called_with_invalid_index() {
207+
bashunit::spy ps
208+
209+
ps first
210+
211+
assert_same \
212+
"$(bashunit::console_results::print_failed_test \
213+
"Unsuccessful spy nth called with invalid index" \
214+
"expected call" "at index 5 but" "only called 1 times")" \
215+
"$(assert_have_been_called_nth_with 5 ps "first")"
216+
}

0 commit comments

Comments
 (0)