Skip to content

fix(settings): make tracking toggle take effect immediately (issue #20) - #23

Open
linletian wants to merge 8 commits into
feature/deepseek-weekend-offpeakfrom
fix/issue20-services-tracking-toggle
Open

fix(settings): make tracking toggle take effect immediately (issue #20)#23
linletian wants to merge 8 commits into
feature/deepseek-weekend-offpeakfrom
fix/issue20-services-tracking-toggle

Conversation

@linletian

Copy link
Copy Markdown
Owner

Summary

修复 GitHub issue #20 — settings → Services 标签下,关闭某 instance 的 "tracking toggle" 后,状态栏仍显示该 instance 的 slot;同时修复 "进入实例取消所有 metrics 后无法保存" 的次生缺陷。

Root causes

ID 位置 现状
R1 SettingsViewModel.setInstanceTrackingEnabled 只改本地 instances[index].trackingEnabled,不通知 AppState
R2 AppState.setInstances / mergeCycleResult 清理 _slotViewDataList 只按"已删除"UUID 过滤,不按"已禁用"过滤
R3 MenuBarController.renderIcon 直接用 latestSlotData 渲染(依赖上游修复)
R4 InstanceEditorView.isFormFilled 强制 !selectedMetrics.isEmpty,编辑现有 instance 时无法保存空 metrics
R5 AppState / AppStateProxy setInstanceTracking(uuid:enabled:) 单实例 toggle 入口
R6 SettingsViewModel.discardChanges 仅回滚本地,不回滚 AppState 运行时状态

正向佐证:RefreshService.performRefresh line 335 的 enabled 过滤、LimitRolloverDetectorNotificationManagerinstance.enabled 守卫早已正确。本 fix 之前的"实际刷新好像无效"现象不是刷新逻辑错误,而是状态栏 slot 残留让用户误以为还在刷新。

Changes (5 commits)

  1. fix(appstate): prune slots for disabled instances — 抽 pruneDisabledSlots() 私有 helper,setInstances / mergeCycleResult 末尾过滤;新增 setInstanceTracking(uuid:enabled:)
  2. fix(proxy): expose setInstanceTracking to view modelsAppStateProxy async 包装,调用 appState.setInstanceTracking + syncFromState
  3. fix(settings): make tracking toggle take effect immediatelySettingsViewModel.setInstanceTrackingEnabled 改 async 即时通知;discardChanges 改 async 逐 UUID 回滚;SettingsView.onToggleTrackingSettingsWindow "Don't Save" 路径同步改 Task 包装。
  4. fix(editor): allow saving an existing instance with zero metricsInstanceEditorView.isFormFilled 改为允许编辑现有 instance 时空 metrics(新建仍要求至少 1 个);抽 static evaluateFormState(...) 纯函数作为测试入口;新增 "Disabling all metrics will pause usage tracking" 警告 banner。
  5. docs(issue20): sync tracking toggle semantics in ARCHITECTURE §2.6 — 文档同步。

Design decisions

  • Toggle 改为即时生效(不再要求点 Save Changes)— 符合 switch UI 直觉
  • AppState 持有 trackingEnabled 权威值,所有 set 路径统一清理 disabled slot,单一真相
  • InstanceEditorView 允许空 metrics(仅编辑现有 instance),新建仍要求至少 1 个
  • 不动 ColorState.disabled 死代码MenuBarIconRenderer.expandToMetricSlots 内的 colorState != .disabled 过滤)— 留待未来独立清理 PR

Tests

新增 3 个 test 文件,23 个 case:

文件 case 数 覆盖
AppStateTrackingToggleTests 9 setInstanceTracking off/on/no-op/unknown uuid / setInstances 清理 / mergeCycleResult 清理(success + error)/ toggle-off → on → refresh lifecycle
SettingsViewModelTrackingToggleTests 6 toggle 即时 AppState 通知(off + on)/ hasUnsavedChanges 标记 / discard 回滚 / net-revert / save 持久化
InstanceEditorViewValidationTests 8 new vs edit × metrics empty vs populated × shortName 规则 / banner 可见性 / MiniMax-no-models 短路

总计 394 tests, 0 failures(baseline 371 + 新增 23)。

Manual verification checklist

产品侧验证(请 reviewer 跑):

  1. 打开 app → settings → Services → 关闭某 instance toggle → 状态栏立刻移除该 slot
  2. StatusDotView 由绿变灰
  3. 顶部出现 "Save Changes" / "Discard Changes"
  4. 重新打开 toggle → 状态栏 slot 恢复(等下次刷新后)
  5. 进入该 instance → 取消所有 metrics → "Save" → 保存成功
  6. 编辑器顶部出现 "Disabling all metrics will pause usage tracking" 警告
  7. 重新打开 editor → 加回 1 个 metric → "Save" → 状态栏下次刷新后恢复
  8. 重启 app → disabled 状态从磁盘正确恢复
  9. "Discard Changes" 正确回滚运行时状态(不会让 toggle 的运行时变更泄漏)
  10. save() 持久化 toggle 状态到 instances.json(重载后保留)

Mechanical adjustments (vs plan)

  • InstanceEditorView.isFormFilled / isAllMetricsDisabled 抽为 static evaluateFormState(...) 纯函数(instance computed property 委托它)— SwiftUI view 构造时 @State 默认值无法在 XCTest 触发 .onAppear 加载,static helper 让测试可直接调。
  • 手工改 project.pbxproj(每个新 test 文件 4 处登记),本机未装 xcodegenbrew install xcodegen 长时间无响应。如未来切回 xcodegen 流程,git restore APIUsageStatus.xcodeproj/project.pbxproj && xcodegen generate 重新生成即可。

Out of scope

References

Toggling the tracking switch in Settings → Services now removes the
instance's slot from AppState._slotViewDataList, so the menu bar
stops rendering the disabled instance. The pruning runs on every
state-mutation path (setInstanceTracking / setInstances /
mergeCycleResult) via a shared private helper, so a toggle that
flips between cycle start and merge cannot be re-introduced by a
late supplier success.

The new setInstanceTracking(uuid:enabled:) method is the single
authoritative update path; SettingsViewModel will use it to make the
toggle take effect immediately in the next commit.

Tests: 9 new cases in AppStateTrackingToggleTests covering each
mutation path and the toggle-off → on → refresh lifecycle.
Thin async wrapper that calls AppState.setInstanceTracking and
syncs the @published snapshot, mirroring the existing updateInstance
pattern. Used by SettingsViewModel in the next commit to make the
tracking toggle take effect immediately.
The toggle in Settings → Services now propagates to the runtime
AppState without waiting for Save Changes. The local draft is
still updated, so hasUnsavedChanges keeps showing the toggle as
a pending commit until the user clicks Save (or Discard).

discardChanges() is now async and rolls the runtime tracking
flag back per-UUID (instead of a full setInstances rebuild that
would re-flash the menu bar for every unrelated instance).
The SettingsWindow 'Don't Save' path awaits the rollback before
closing the window to keep the user-visible state consistent.

The two new test files (AppStateTrackingToggleTests, this one)
are added to the APIUsageStatusTests target via project.pbxproj
so the suite actually runs them. xcodegen is not installed on
this machine; the project.pbxproj updates mirror the existing
test-file registration pattern.

Tests: 6 new cases in SettingsViewModelTrackingToggleTests
covering immediate propagation, hasUnsavedChanges semantics,
discard rollback, revert-to-original behavior, and durable
save() persistence.
#20)

Editing an existing instance now lets the user clear every metric
and save — the user is intentionally pausing tracking for that
entry. New-instance behavior is unchanged: at least one metric is
still required so we never persist fully unconfigured entries.

A warning banner ("Disabling all metrics will pause usage
tracking") surfaces only in the edit-with-zero-metrics state so
the user has a clear signal about the side effect on the menu bar
slot.

The validation rules are extracted into a static
`evaluateFormState(...)` helper. The instance computed
properties (isFormFilled, isAllMetricsDisabled) delegate to it, so
the runtime and unit-test paths share one implementation. The
helper is the test entry point — SwiftUI's @State defaults can't
be observed from XCTest, so the unit tests exercise the pure
function directly.

Tests: 8 new cases in InstanceEditorViewValidationTests covering
new/edit/populated/empty combinations, shortName rules, banner
visibility, and the MiniMax-no-models short-circuit.
Add 'Tracking toggle semantics (issue #20)' subsection covering
the AppState mutation contract (setInstanceTracking /
setInstances / mergeCycleResult / pruneDisabledSlots), the
toggle-path flow from SettingsView down to the menu-bar
re-render, the interaction with RefreshService's already-correct
enabled filter, and an explicit note that the existing
ColorState.disabled branches in MenuBarIconRenderer are still
dead code (deliberately left for a future cleanup PR — see
the companion .omo/plans/fix-issue20-services-tracking-toggle.md
local record per AGENTS.md §7).
@linletian

Copy link
Copy Markdown
Owner Author

Review — PR #23 (issue #20 tracking toggle)

Implementation is clean: pruneDisabledSlots is well-placed at every state-mutation site, setInstanceTracking short-circuits on no-op with a meaningful return, the per-UUID discardChanges avoids full re-flashes, and the new tests cover the full toggle lifecycle. One substantive defect and four minor issues below.

Blocking

1. Warning banner text contradicts the actual menu-bar behaviorAPIUsageStatus/Views/InstanceEditorView.swift:239-242

"The menu bar slot will disappear until you re-enable at least one metric."

The slot is removed only when trackingEnabled == false (AppState.swift:213-217, AppState.swift:158-162). When the user unchecks all metrics in the editor but leaves the tracking switch on and saves, the next refresh still emits a slot for that instance: mapInstanceToSlotData iterates an empty instance.metrics and produces metricSnapshots: [], and MenuBarIconRenderer.expandToMetricSlots:309-312 unconditionally appends the empty-snapshot slot to the render set (the colorState != .disabled guard is dead code — grep ColorState.disabled returns zero matches project-wide). The user keeps seeing the instance shortName in the menu bar with no metric data, contradicting the banner.

Reproducer: Services → edit instance → uncheck every metric → Save. The menu bar keeps the slot; only the numbers go away.

Fix options (any one):

  • Reword the banner to match reality: "The menu bar will stop showing usage data for this instance…"
  • Or actually prune the empty-snapshot slot: in mapInstanceToSlotData return nil for instance.metrics.isEmpty (also folds in the future ColorState.disabled cleanup mentioned in §2.6 of docs/ARCHITECTURE.md).

Non-blocking (suggested before merge if scope allows)

2. discardChanges skips rollback when an instance was deleted from the draftSettingsViewModel.swift:83-91

The rollback loop looks up the instance in the current instances array. Sequence: toggle A's tracking off → delete A from the draft (only mutates local; appState._instances still holds A with trackingEnabled=false) → click Discard. first(where:) returns nil, the loop skips, then instances = originalInstances restores A to local with trackingEnabled=true — but appState._instances is left with trackingEnabled=false. Inconsistency persists until the next save/toggle. Either snapshot tracking at load time, or have deleteInstance also call appStateProxy.setInstanceTracking(uuid:, false) so the discard path stays whole.

3. Stale doc commentAPIUsageStatus/Models/Instance.swift:16

SettingsViewModel.setInstanceEnabled writes to this setter.

setInstanceEnabled is now async (this PR's change) and has no callers project-wide (grep setInstanceEnabled returns only the deprecated declaration). Remove the comment and the dead deprecated entry.

4. Self-contradictory comment in testSavePersistsToggleStateSettingsViewModelTrackingToggleTests.swift:209-213

The comment says "empty instances list" then "single no-provider instance" then cites the empty enabledInstances short-circuit — none of which match the test (it never empties the list). The actual mechanism is SupplierRegistry.getSupplier(for: "test") returning nil and RefreshService.swift:454-466 graceful-degrading into an errorSummaries entry instead of throwing. Test passes for the right reason, but the comment will mislead. Either empty instances in the test or rewrite the comment.

5. Banner fires on form open for already-paused instancesInstanceEditorView.swift:761-769

isAllMetricsDisabled is isEditing && selectedMetrics.isEmpty; if a user opens an instance that was previously saved with zero metrics, loadExistingData() writes selectedMetrics = [] and the banner shows immediately, with forward-looking wording ("Disabling all metrics will pause usage tracking") for an already-paused state. Track a hasUserTouchedMetrics flag alongside selectedMetrics to gate the banner on user action.

What works

  • pruneDisabledSlots is the right abstraction; reading _instances inside the actor avoids TOCTOU.
  • setInstanceTracking returning Bool lets future callers suppress no-op side effects.
  • The per-UUID discard roll-back is a smart choice over a full setInstances rebuild (correctly justified in the comment).
  • evaluateFormState as a static pure function is the right way to lock SwiftUI validation rules from XCTest.
  • 23 new tests + the §2.6 doc sync line up with AGENTS.md §7's "code + doc same commit" rule.

…review)

PR #23 review surfaced two related issues with the warning banner
that fires when the user clears every metric in the edit form.

**1. Banner text contradicted actual behavior.** The old copy
said 'The menu bar slot will disappear', but `AppState` only
prunes a slot when `trackingEnabled == false`. Saving with
zero metrics while tracking stays on leaves the shortName
visible — `mapInstanceToSlotData` produces an empty-snapshot
slot and `MenuBarIconRenderer.expandToMetricSlots` appends
it unconditionally (the `.disabled` guard is dead code).
Reworded the banner to match reality: shortName stays, no
data renders until a metric is re-enabled.

**2. Banner fired on form open for already-paused instances.**
`isAllMetricsDisabled` is a function of `(isEditing,
selectedMetrics.isEmpty)`; opening an instance that was
previously saved with zero metrics made the banner pop
immediately with forward-looking wording for a state the user
didn't just enter. Tracked a new `@State
hasUserTouchedMetrics` that the form's load hook resets and
each metric-toggle mutates. The banner now only surfaces when
the user is the one doing the disabling.

Added a static helper `shouldShowAllMetricsBanner(...)` so
the gating logic is testable from XCTest (SwiftUI @State
defaults can't be observed from XCTest, same pattern as
`evaluateFormState`). Three new cases in
InstanceEditorViewValidationTests cover the open-vs-touch
distinction and the new/edit/populated matrix.
 review)

PR #23 review surfaced a discard-path inconsistency: the
previous `deleteInstance` only mutated the local draft, so a
sequence like "toggle A off → delete A → Discard" left
`appState._instances` with A still in it (tracking off)
while the local draft had A back (tracking on). The slot
stayed pruned past the Discard.

Added a per-UUID removal path on `AppState`:
- `AppState.removeInstance(uuid:)` evicts the UUID from both
  `_instances` and `_slotViewDataList` (mirrors
  `setInstanceTracking`'s single-UUID, immediate-propagation
  semantics)
- `AppStateProxy.removeInstance(uuid:)` wraps it
- `SettingsViewModel.deleteInstance` now awaits the proxy
  call after the local-draft mutation, matching the toggle
  path

`discardChanges` now also calls `appState.setInstances` after
the per-UUID toggle rollback. The per-UUID loop is still the
fast path for toggle-only drafts (no slot re-flash for
unrelated instances), but a draft that added or removed an
instance needs the full rewrite so the runtime catches up.
`setInstances` already runs `pruneDisabledSlots` so the
final state matches the restored tracking flags.

Tests: 2 new cases in AppStateTrackingToggleTests
(unknown-UUID no-op, happy-path eviction from both
_instances and _slotViewDataList) + 2 new cases in
SettingsViewModelTrackingToggleTests (deleteInstance
propagates to AppState, toggle+delete+discard stays
consistent).
#23 review)

PR #23 review surfaced two dead-code / stale-doc items.

**Stale doc comment** — `Instance.swift` line 16 claimed
`SettingsViewModel.setInstanceEnabled writes to this setter`.
`setInstanceEnabled` is now `async` and was the *only*
caller of that setter; with it gone, the comment was
misleading. Replaced with an explanation of why the bridge
itself stays (v1 JSON schema compat).

**Dead deprecated entry** — `SettingsViewModel.setInstanceEnabled`
was the only place project-wide that wrote through
`Instance.enabled`. With the only caller gone, the entire
method is dead. Removed the `@available(\*, deprecated, ...)`
declaration.

**Self-contradictory test comment** —
`testSavePersistsToggleState` claimed the test avoided the
supplier by using an empty instances list / a single
no-provider instance / the `enabledInstances` short-circuit.
None of those describe what the test actually does: the
"test" provider has no registered `Supplier` in
`SupplierRegistry`, so `RefreshService.performRefresh`
short-circuits via the registry lookup (see
`RefreshService.swift:454-466`). Rewrote the comment to
match the real mechanism so future readers don't go chasing
the wrong code path.
@linletian

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review — all five issues were accurate. Fixes are pushed in three follow-up commits (branch fix/issue20-services-tracking-toggle, now at 644ea9a, ahead of the original 9a4a9f1 head by three commits).

# Severity Status Commit Note
1 Blocking fixed d46f0e4 Reworded to "shortName stays in the menu bar but no number or status will be shown" + added hasUserTouchedMetrics user-action gate so the forward-looking banner only fires when the user is the one disabling. Chose (A) over (B) because (B) would have folded the future ColorState.disabled cleanup into this PR and conflicted with the "暂停跟踪 vs 暂停通知" issue I'd like to file separately.
2 Non-blocking fixed e560a31 Added per-UUID AppState.removeInstance (mirrors setInstanceTracking's contract), AppStateProxy.removeInstance, and made SettingsViewModel.deleteInstance await the proxy. discardChanges now appends a setInstances after the per-UUID toggle rollback so add/remove-style drafts are also whole on discard — kept the per-UUID loop as the fast path for toggle-only drafts.
3 Non-blocking fixed 644ea9a Removed the dead setInstanceEnabled (only call site was the now-async path it was delegating to) and replaced the misleading Instance.swift:16 comment with an accurate description of why the enabled bridge itself stays (v1 JSON compat).
4 Non-blocking fixed 644ea9a Rewrote the test docstring to point at the actual mechanism: the "test" provider has no registered Supplier in SupplierRegistry, so RefreshService.performRefresh short-circuits via the registry lookup (RefreshService.swift:454-466) and degrades into an errorSummaries entry.
5 Non-blocking fixed d46f0e4 Tracked @State hasUserTouchedMetrics reset by loadExistingData and set by each of the four toggle functions (toggleKimiMetric / toggleMiniMaxModel / toggleMiniMaxWindow / toggleOpenCodeMetric). Extracted shouldShowAllMetricsBanner(...) as a static helper for testability.

Test coverage added in the three commits: 7 new cases (3 in InstanceEditorViewValidationTests for the banner gate, 2 in AppStateTrackingToggleTests for removeInstance happy path + unknown-UUID no-op, 2 in SettingsViewModelTrackingToggleTests for the delete → discard consistency). Total suite: 401 tests, 0 failures (was 394 before this PR; +7 from the review fixes).

Release build verified: APIUsageStatus.app 9.5 MB Mach-O universal, ad-hoc signed, staged in the repo root and ready for /Applications deployment.

Happy to re-cut any of the commits or push a follow-up if you'd prefer (B) on issue #1 — but as noted I think (A) is the right call given the "暂停跟踪 vs 暂停通知" feature is in the planned future work and the two designs want different shape from mapInstanceToSlotData.

@linletian

Copy link
Copy Markdown
Owner Author

Re-review — PR #23 (fixes addressing the previous review)

All five items from the previous review are addressed cleanly:

One new finding from the fixes worth addressing before merge:

Suggestion (not blocking) — discardChanges per-UUID loop is now dead code

APIUsageStatus/Views/SettingsViewModel.swift:79-91

After this PR, discardChanges runs the per-UUID toggle rollback and then the full appState.setInstances(instances) at line 107. Tracing the toggle-only path:

  1. Per-UUID loop: for each toggled instance A, calls appStateProxy.setInstanceTracking(uuid: A, enabled: originalValue). Inside the actor this sets _instances[i].trackingEnabled = originalValue — the if !enabled branch is false, so the slot buffer is untouched. The proxy then calls syncFromState(), republishing @Published.
  2. Local restore (instances = originalInstances).
  3. appState.setInstances(instances) (line 107) replaces _instances wholesale with the same originalInstances, and runs pruneDisabledSlots (no-op for this scenario). Then syncFromState() republishes again.

The per-UUID loop's effect on _instances[i].trackingEnabled is immediately overwritten by step 3, and the loop never touches the slot buffer in the rollback direction (setInstanceTracking(_, true) doesn't add slots back). So the loop is doing N+1 syncFromState() round-trips for zero observable state change — exactly the work the loop's own comment claims it was avoiding ("avoids a full _instances rewrite").

The comment at lines 79-82 ("preserves slot data for unrelated instances by avoiding a full _instances rewrite") is now misleading — line 107 does do the full rewrite.

The comment at lines 99-106 ("insufficient when the draft also added or removed instances — those need a full setInstances") also now misreads as if the two passes are cooperating; in practice the per-UUID pass is irrelevant once the full pass is unconditional.

Cleanest fix: drop the per-UUID loop, rename the comment block to "Roll back the runtime _instances so Discard and the next save see the same baseline", and keep the setInstances + updateSettings + syncFromState triad. The slot-buffer note ("next refresh rebuilds the slot") is the only non-obvious part worth preserving in a comment, since the test's own docstring already calls it out.

Test gap (informational) — "ghost slot" after Discard is acknowledged but not asserted

APIUsageStatusTests/SettingsViewModelTrackingToggleTests.swift:240-260 testDeleteThenDiscardStaysConsistent

The test's docstring at line 239 says "both sides agree 'A is alive, tracking on, no slot yet'" but the body only asserts _instances consistency. If the design intent is "no slot until next refresh", an explicit XCTAssertTrue(slots.isEmpty) (or XCTAssertNil(slots.first { $0.uuid == "inst-1" })) would lock the behavior and prevent a future refactor from silently re-introducing a stale slot. Same shape for the toggle-only Discard path (testDiscardChangesRollsBackAppState at line 153) — the runtime _instances is verified but not the slot buffer.

Not blocking; flagging because the test's own comment treats "no slot yet" as part of the contract.

What works well in the new commits

  • AppState.removeInstance mirrors setInstanceTracking's @discardableResult Bool return + missing-UUID no-op. Symmetry is good and the new test testRemoveInstanceUnknownUuidIsNoOp pins it.
  • hasUserTouchedMetrics is reset in loadExistingData and only flipped by the metric-toggle helpers (toggleMiniMaxWindow, toggleOpenCodeWindow, toggleKimiWindow around lines 355/659/674/687 of InstanceEditorView.swift) — clean coverage of every entry point that mutates the selection.
  • shouldShowAllMetricsBanner mirrors the evaluateFormState static-helper pattern, so XCTest can drive the gating without touching SwiftUI @State.
  • Adding appState.updateSettings(settings) to discardChanges silently fixes a pre-existing gap (the old sync discardChanges never rolled back runtime settings either).
  • The new testDeleteInstanceImmediatelyNotifiesAppState correctly seeds AppState via appState.setInstances after viewModel.load() and uses the shared appState from setUp — the test wiring matches the production wiring (modulo appStateProxy.initialize()), with a comment that explains the gap.

Recommendation: address the dead-loop cleanup before merge; the test gap is informational and can land in a follow-up.

@linletian

Copy link
Copy Markdown
Owner Author

Thanks for the re-review — both findings checked out against the code. Addressed in three commits on fix/issue20-services-tracking-toggle (now at 198bfd5).

# Severity Status Commit Note
1 Non-blocking fixed 0edf009 Dead per-UUID loop removed. Verified the trace before touching anything: the loop's _instances[i].trackingEnabled writes are wholesale-overwritten by the unconditional setInstances(originalInstances), and its only unique side effect (slot pruning when rolling back to disabled) is redone by pruneDisabledSlots inside setInstances — N actor hops + N syncFromState() republishes for zero final-state difference, exactly as described. Dropped the loop, kept the setInstances + updateSettings + syncFromState triad, and rewrote the comment to state the actual contract (one wholesale rollback covers toggle/add/remove draft shapes; slots evicted mid-draft stay gone until the next refresh rebuilds them). Also swept two references that still described the old mechanism: AppState.setInstanceTracking's doc comment ("a discard rolls this value back via the same method") and ARCHITECTURE.md §"Tracking toggle semantics" step 5.
2 Informational fixed 198bfd5 Slot-buffer contract now asserted. Both discard tests seed a real slot via mergeCycleResult instead of relying on an always-empty buffer. testDiscardChangesRollsBackAppState pins: toggle-off prunes the slot immediately → discard restores the tracking flag but not the slot. testDeleteThenDiscardStaysConsistent pins: discard restores the instance but not its slot — the next refresh rebuilds it.

A third commit, 5757275, piggybacks a pre-existing base-branch bug that was blocking the test gate locally: OpenCodeWorkspaceResolver.knownGoodSample lacked the trailing / that idRegex's (?=/) lookahead requires, so the debug-only validateFormatContract assert trapped deterministically on any debug call into resolveWorkspaceID() — including the XCTest host app at launch (in since 473f4c8). Same fix as d0495c0 on feature/opencode-go-usage-api, confirmed with a Swift REPL repro before and after.

Test status: every suite touched by these commits is green — SettingsViewModelTrackingToggleTests (with the new slot assertions), AppStateTrackingToggleTests, InstanceEditorViewValidationTests, OpenCodeWorkspaceResolverTests, RefreshServiceCycleSlotTests — along with all other non-snapshot suites.

One environmental caveat on the full-suite number: FlowingGlowBarTests and MenuBarIconRendererTests (the golden-master snapshot suites) currently fail on this machine with EPERM while reading the reference PNGs under ~/Documents — macOS TCC is denying the ad-hoc test host Documents-folder access. Verified unrelated to these changes: the identical suites passed ~18h earlier on feature/opencode-go-usage-api, the files read fine from the shell, and the failure reproduces deterministically in isolation with NSCocoaErrorDomain 257. Will re-run the full suite and post the count once the local TCC grant is sorted.

@linletian

Copy link
Copy Markdown
Owner Author

Re-review (round 3) — PR #23 final pass

Both findings from the previous review are addressed cleanly and the code is in a state I'd be comfortable approving.

✅ 0edf009 — dead per-UUID loop removed

APIUsageStatus/Views/SettingsViewModel.swift:78-98 is now a single-pass wholesale reset: instances = originalInstancessetInstancesupdateSettingssyncFromState. The new comment (lines 85-92) honestly describes the three contract points:

  • One setInstances covers every draft shape (toggles, additions, removals).
  • pruneDisabledSlots re-prunes any slots whose owner is disabled again.
  • Slots evicted by a mid-draft toggle/delete intentionally stay gone until the next refresh rebuilds them — and the comment explicitly cross-references testDeleteThenDiscardStaysConsistent as the contract pin.

The stale-reference sweep is also good:

  • AppState.swift:80-83 now says "a discard restores the baseline wholesale via setInstances" instead of the old "discard rolls this value back via the same method" claim.
  • docs/ARCHITECTURE.md:214 (the Chinese step-5 description) is updated to match the new mechanism and to mention the "草稿期间被裁剪的槽位不复活,下次刷新重建" invariant.

✅ 198bfd5 — slot-buffer contract pinned

Both tests now seed a real slot via mergeCycleResult (with a tidy private makeSlot helper) and then assert the "no slot yet" half of the contract explicitly:

  • testDiscardChangesRollsBackAppState asserts the slot is nil both after the toggle-off and after the discard — covering the "toggle prunes, discard doesn't resurrect" path.
  • testDeleteThenDiscardStaysConsistent asserts the slot is nil after the delete+discard sequence — covering the "delete evicts, discard doesn't re-add" path.

The new assertions use the same XCTAssertNil(slots.first { $0.uuid == ... }) shape, so the contract reads identically across the two tests. Good.

No new findings

I checked the remaining surface area of the PR for any issues the two refactors might have introduced:

  • Slot buffer re-prune: after discardChanges calls setInstances(originalInstances), pruneDisabledSlots will only remove slots whose owner is in the new list with trackingEnabled=false. Since all restored instances come from the original load (all trackingEnabled=true for instances that were enabled at load time), the prune is a no-op in the common case. For the case where a previously-disabled instance was somehow toggled-on via the draft, the slot buffer won't get auto-rebuilt — but the new contract is honest about that.
  • Comment cross-reference is correct: testDeleteThenDiscardStaysConsistent does pin the "no slot yet" contract, so the comment at line 91 pointing to it is accurate.
  • updateSettings ordering: setInstances then updateSettings then syncFromState matches the call order in save() (just without the triggerManualRefresh afterwards) — symmetric and easy to reason about.
  • No leftover references to the per-UUID mechanism: grep -n "per-UUID\|逐 UUID" APIUsageStatus/ docs/ returns nothing material — the cleanup commit was thorough.

Final state

The PR is 12 commits, 1225 additions / 20 deletions, with clean test coverage at every layer (parser/cycle slot, view-model rollback, form validation, slot-buffer invariants). The five items from the first review and the two items from the second are all resolved with no regressions introduced. Ready to merge from my side.

Thanks for the iteration — the final shape of discardChanges is much easier to reason about than the per-UUID + wholesale hybrid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant