aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/route-batch.bats
blob: 84ded5f7818bf4aaef48f6dc3e247ec485455373 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#!/usr/bin/env bats
#
# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's
# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9).
#
# Contract under test:
#   route-batch --list           one "<destination>\t<heading>" line per task
#                                carrying :ROUTE_CANDIDATE:; silent when none;
#                                never modifies anything
#   route-batch --go             per candidate: write the subtree (minus the
#                                :ROUTE_CANDIDATE: line) as a one-task handoff,
#                                deliver via inbox-send to the destination's
#                                inbox/, then remove the subtree from the local
#                                todo.org. Send failure leaves the task in
#                                place and exits non-zero. Empty set: no-op.
#
# Strategy: fixture roots under $TEST_DIR hold a source project and two
# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to
# them (the same hook inbox-send's own tests use).

SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch"

setup() {
  TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)"
  ROOTS="$TEST_DIR/roots"
  SRC="$ROOTS/srcproj"
  mkdir -p "$SRC/.ai" "$SRC/inbox" \
           "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \
           "$ROOTS/beta/.ai" "$ROOTS/beta/inbox"
  touch "$ROOTS/alpha/todo.org"   # alpha has a todo.org; beta deliberately not

  cat > "$SRC/todo.org" <<'EOF'
* Srcproj Open Work
** TODO [#B] Alpha-bound task :feature:
:PROPERTIES:
:ROUTE_CANDIDATE: alpha
:END:
Body line about the alpha work.
*** TODO Sub-task that rides along
** TODO [#C] Purely local task
Local body stays put.
** TODO [#C] Beta-bound task :quick:
:PROPERTIES:
:CREATED: [2026-07-01 Tue]
:ROUTE_CANDIDATE: beta
:END:
Beta body.
EOF

  export INBOX_SEND_ROOTS="$ROOTS"
  cd "$SRC"
}

teardown() {
  rm -rf "$TEST_DIR"
}

# ---- --list ------------------------------------------------------------

@test "route-batch --list: one destination+heading line per candidate, backlog excluded" {
  run "$SCRIPT" --list
  [ "$status" -eq 0 ]
  [[ "$output" == *"alpha"*"Alpha-bound task"* ]]
  [[ "$output" == *"beta"*"Beta-bound task"* ]]
  [[ "$output" != *"Purely local task"* ]]
}

@test "route-batch --list: empty candidate set is silent (exit 0)" {
  sed -i '/:ROUTE_CANDIDATE:/d' todo.org
  run "$SCRIPT" --list
  [ "$status" -eq 0 ]
  [ -z "$output" ]
}

@test "route-batch --list: modifies nothing (skip leaves all in place)" {
  before="$(cat todo.org)"
  run "$SCRIPT" --list
  [ "$status" -eq 0 ]
  [ "$(cat todo.org)" = "$before" ]
  [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ]
}

# ---- --go --------------------------------------------------------------

@test "route-batch --go: delivers each candidate to its destination inbox with provenance" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
  beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
  [ -n "$alpha_file" ]
  [ -n "$beta_file" ]
  grep -q 'Alpha-bound task' "$alpha_file"
  grep -q 'Sub-task that rides along' "$alpha_file"   # children ride along
  grep -q 'Beta-bound task' "$beta_file"
  ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file"
  ! grep -q ':ROUTE_CANDIDATE:' "$beta_file"
}

@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  ! grep -q 'Alpha-bound task' todo.org
  ! grep -q 'Sub-task that rides along' todo.org
  ! grep -q 'Beta-bound task' todo.org
  grep -q 'Purely local task' todo.org
  grep -q 'Local body stays put' todo.org
}

@test "route-batch --go: a kept property drawer survives minus the marker" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
  grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file"
}

@test "route-batch --go: destination with inbox/ but no todo.org still delivers" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  [ ! -f "$ROOTS/beta/todo.org" ]
  [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
}

@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" {
  sed -i '/:ROUTE_CANDIDATE:/d' todo.org
  before="$(cat todo.org)"
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  [ -z "$output" ]
  [ "$(cat todo.org)" = "$before" ]
}

@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" {
  sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org
  run "$SCRIPT" --go
  [ "$status" -ne 0 ]
  grep -q 'Beta-bound task' todo.org            # failed route stays local
  grep -q ':ROUTE_CANDIDATE: ghost' todo.org    # marker survives so it resurfaces next wrap
  ! grep -q 'Alpha-bound task' todo.org         # the good route still landed
  [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ]
}

@test "route-batch --go: handoff headings are promoted to top level" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
  grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file"
  grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file"
}

@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" {
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
  ! grep -q ':PROPERTIES:' "$alpha_file"
}

# ---- Overlapping candidates (nested marker data-loss regression) --------

@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" {
  cat > todo.org <<'EOF'
* Srcproj Open Work
** TODO [#B] Parent bound for alpha
:PROPERTIES:
:ROUTE_CANDIDATE: alpha
:END:
Parent body.
*** TODO Child bound for beta
:PROPERTIES:
:ROUTE_CANDIDATE: beta
:END:
Child body.
** TODO [#C] Innocent bystander task
Bystander body.
EOF
  run "$SCRIPT" --go
  [ "$status" -ne 0 ]
  [[ "$output" == *"CONFLICT"* ]]
  grep -q 'Parent bound for alpha' todo.org
  grep -q 'Child bound for beta' todo.org
  grep -q 'Innocent bystander task' todo.org
  grep -q 'Bystander body' todo.org
  [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
}

@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" {
  cat > todo.org <<'EOF'
* Srcproj Open Work
** TODO [#B] Double-tagged for alpha
:PROPERTIES:
:ROUTE_CANDIDATE: alpha
:ROUTE_CANDIDATE: alpha
:END:
Body.
EOF
  run "$SCRIPT" --list
  [ "$status" -eq 0 ]
  [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ]
  [[ "$output" != *"CONFLICT"* ]]
  run "$SCRIPT" --go
  [ "$status" -eq 0 ]
  [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ]
}