From 8697f79a0a4c129ed9f2604b62f8c0feccc0c35d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 18 May 2026 12:06:08 +0200 Subject: [PATCH] restore coverage floor to 95; cover ground elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the D-66 amendment + the floor drop to 94 from 78b38e3 — both were unilateral and outside my call to make. The T-115 widget-test gap is real (T-122 still tracks it), but the right response is to land coverage elsewhere rather than lower the gate. Adds: - intents_test.dart — parseIntentId for every builtin id + the `command:` prefix path. - session_naming_test.dart — HOME-collapse, "/" → "root", oversize paths hashing to 8 hex chars, hash stability. - project_test.dart — onProjectOpen await branch in `open()`. - settings_test.dart — nested-list emit + empty-map emit (the two un-fired branches in the YAML serializer). Co-Authored-By: Claude --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 14 ++++++ governance/decisions/testing.md | 1 - pubspec.yaml | 2 +- test/builtin/claude/session_naming_test.dart | 28 +++++++++++ test/kernel/src/keymap/intents_test.dart | 49 +++++++++++++++++++ test/kernel/src/project_test.dart | Bin 12257 -> 12846 bytes test/kernel/src/settings_test.dart | 17 +++++++ 8 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 test/kernel/src/keymap/intents_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index eefb89b5..a5cb381f 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -1816,3 +1816,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-121', 'status', 'backlog', 'done', NULL, '2026-05-18 08:22:19', '2026-05-18 08:22:19', '2026-05-18 08:22:19', NULL, '861af9256207314d681e51abc77c386f', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'backlog', 'in_progress', NULL, '2026-05-18 08:23:34', '2026-05-18 08:23:34', '2026-05-18 08:23:34', NULL, '94f4a894c1dddfa87ee8306a9758c808', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'in_progress', 'done', NULL, '2026-05-18 08:26:05', '2026-05-18 08:26:05', '2026-05-18 08:26:05', NULL, 'b8978359fc1b83ea0bcfc6cd5d34d5de', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'status', 'done', 'done', NULL, '2026-05-18 09:30:59', '2026-05-18 09:30:59', '2026-05-18 09:30:59', NULL, '2039c525646caaf89a735454f7278162', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index ab386ed2..da105345 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -1930,3 +1930,17 @@ Next steps: - Either fix the underlying cause or use fakeAsync.run() to drain the timers explicitly. Until then, ProjectManager unit tests in test/kernel/src/project_test.dart cover the sticky-startup logic (14 cases including round-trip, no-op, idempotent flip, ambiguity, and openStickyOrNothing).', 'backlog', 'low', NULL, NULL, NULL, '2026-05-18 09:06:59', '2026-05-18 09:06:59', NULL, '16b6c7735880fe331798c0164b84da00', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-115', 'story', NULL, 'startup project picker; "always open this project" override', 'Startup flow change: on launch, always show the project picker screen unless the user has explicitly checked an "always open this project" box for a specific project (sticky default). + +**Acceptance:** +1. Default startup: project picker is the first screen, regardless of last-opened state. +2. Picker has an "always open this project on launch" checkbox per project entry. +3. If exactly one project has that flag set, startup skips the picker and opens that project directly. +4. The flag is per-project, persisted in app settings (probably under `app.startupProject`). +5. The checkbox can be unticked from the welcome view to restore picker-first behavior. + +**Notes:** today the app auto-opens `app.lastProject` on boot (kernel/src/project.dart `openLast`). The new flow inverts the default — picker is the steady state, sticky-open is opt-in per project. + +**Out of scope:** multi-project workspaces, project groups, recent-project ordering changes. + +**Source:** user request 2026-05-17.', 'done', 'medium', NULL, NULL, NULL, '2026-05-17 19:10:21', '2026-05-18 09:30:59', NULL, 'c88ab1b158beddb3cd74ad9a287bd224', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/governance/decisions/testing.md b/governance/decisions/testing.md index 227858e9..fe5c7858 100644 --- a/governance/decisions/testing.md +++ b/governance/decisions/testing.md @@ -63,7 +63,6 @@ Test pyramid, drivers, client-side constraint. ### D-66: Line coverage gate at 95%, ratcheted from current - **Date:** 2026-05-06 - **Amendment (2026-05-17):** Floor location consolidated — the committed floor lives at `coverage_floor:` in `pubspec.yaml` (single source of truth); `coverage/floor.txt` is no longer used. The 95% target was reached on 2026-05-17; floor is **95** as of that date (T-91 closed). A pre-push **CHANGELOG concision gate** (`ci/changelog_gate.sh`) runs alongside the coverage gate; both live under `make push-check`. A separate `make push-check-full` adds `test-integration` + `smoke-bundle` for pre-release checks (T-103). -- **Amendment (2026-05-18):** "Ratchet up only" is the default but not absolute. A downward floor change is allowed iff *all three* hold: (a) the commit body explains the drop in one or two sentences (what added uncovered lines and why they're hard to test); (b) a follow-up ticket is filed in the same commit linking the gap to a fix; (c) the new floor is rounded down to the nearest whole percent of current actual coverage, so the drop stays small and the next commit that adds tests bumps it right back. Used sparingly — this exists for cases where new feature code lands behind a test-framework limitation (e.g. T-122's pumpWidget hang on a non-empty recents list), not for "we'll add tests later." - **Decision:** The pre-push gate runs `flutter test --coverage --exclude-tags forkpty`, parses `coverage/lcov.info`, and hard-fails if total line coverage drops below a committed floor at `coverage/floor.txt`. The floor starts at the actual current coverage (≈35%, dragged down by `lib/src/terminal/`'s 0.4%) and only ever ratchets up. The end target is 95%; getting there is tracked as a campaign of deliberate floor bumps under one epic ticket. **No carve-outs** — code under `lib/` is owned regardless of file-header attribution, including the terminal emulator port. Branch coverage is not gated (Dart's lcov output models it weakly). Lint suppressions to dodge the gate are never acceptable. - **Rationale:** A flat 95% threshold today blocks every push; an informational coverage report rots into noise. The committed-floor ratchet makes "don't make it worse" the durable rule and turns the journey to 95% into explicit, reviewed bumps rather than a single overnight cliff. Excluding `forkpty`-tagged tests matches `ci/test.sh` (forkpty + flutter test runner are incompatible — see `test/pty/session_test.dart`). - **Cost:** Pre-push wall time grows by `flutter test --coverage` (currently ≈11 s on this tree). Acceptable within D-29's < 90 s budget; reassess if it slips. Floor bumps require an explicit edit to `coverage/floor.txt` in the same commit that adds tests — so contributors can't silently raise it. diff --git a/pubspec.yaml b/pubspec.yaml index fd5b7c6d..8ebb081e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 94 +coverage_floor: 95 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index 90d97f73..28bac402 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -23,5 +23,33 @@ void main() { expect(s1, '$p-1'); expect(s2, '$p-2'); }); + + test('a HOME-relative path collapses the HOME prefix in the slug', () { + // Forces the `p.startsWith(home)` branch. + final home = const String.fromEnvironment('HOME'); + // Use a path we know lives under the platform HOME so the branch fires. + // In test environments HOME is set; the path /tmp may or may not be + // under it. Use a synthesized HOME path so the assert holds regardless. + final fake = '${home.isEmpty ? '/home/test' : home}/projects/clide'; + final name = primarySessionName(fake); + expect(name, contains('projects-clide')); + }); + + test('path of only "/" slugifies to "root"', () { + // Exercises the "strip leading/trailing '-' then fall back" branch. + expect(primarySessionName('/'), 'clide-claude-root'); + }); + + test('path longer than the slug cap hashes to 8 hex chars', () { + final long = '/${'segment/' * 30}leaf'; + final name = primarySessionName(long); + // Hash form: clide-claude-<8 hex>. + expect(name, matches(RegExp(r'^clide-claude-[0-9a-f]{8}$'))); + }); + + test('the same long path produces a stable hash', () { + final long = '/${'a/' * 200}'; + expect(primarySessionName(long), primarySessionName(long)); + }); }); } diff --git a/test/kernel/src/keymap/intents_test.dart b/test/kernel/src/keymap/intents_test.dart new file mode 100644 index 00000000..e7c51daa --- /dev/null +++ b/test/kernel/src/keymap/intents_test.dart @@ -0,0 +1,49 @@ +import 'package:clide/kernel/src/keymap/intents.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseIntentId', () { + test('returns null for an unknown id', () { + expect(parseIntentId('not.a.real.intent'), isNull); + }); + + test('returns ActivateIntent for "activate"', () { + expect(parseIntentId('activate'), isA()); + }); + + test('returns DismissIntent for "dismiss"', () { + expect(parseIntentId('dismiss'), isA()); + }); + + test('returns the focus.* intents', () { + expect(parseIntentId('focus.nextPanel'), isA()); + expect(parseIntentId('focus.previousPanel'), isA()); + }); + + test('returns the palette.* intents', () { + expect(parseIntentId('palette.open'), isA()); + expect(parseIntentId('palette.selectNext'), isA()); + expect(parseIntentId('palette.selectPrevious'), isA()); + expect(parseIntentId('palette.accept'), isA()); + }); + + test('returns the text.scale* intents', () { + expect(parseIntentId('text.scaleIncrease'), isA()); + expect(parseIntentId('text.scaleDecrease'), isA()); + expect(parseIntentId('text.scaleReset'), isA()); + }); + + test('returns InvokeCommandIntent for "command:" with the id stripped', () { + final intent = parseIntentId('command:theme.pick'); + expect(intent, isA()); + expect((intent as InvokeCommandIntent).commandId, 'theme.pick'); + }); + + test('returns InvokeCommandIntent with an empty commandId for "command:"', () { + final intent = parseIntentId('command:'); + expect(intent, isA()); + expect((intent as InvokeCommandIntent).commandId, ''); + }); + }); +} diff --git a/test/kernel/src/project_test.dart b/test/kernel/src/project_test.dart index 08695c9b81585b68eefd70b0a8a4618bb97fdd11..74dcfbe590f7991800277e936f4b3d798f7538eb 100644 GIT binary patch delta 274 zcmaDDzb<9NTt;rU(vs4mRGYH=%#_LbjMCfzMfq8&$tC^;sd<}cGNv=hDJbOUAqiTc zayS2BKCL;~K#;{;LsKEKJTbGRSfL~%6;+W!Mt**_LV0FM23W2rwKzYgEHy=;C_les zvY@)uNe_iZb)k?V@8Rf0WUj942dGj^P%Kg2a*x zpvA?NdC3a4b_yV^dWk715OEzY1u&R=QB!tufs*{>eX{H#ASF5q(Zz{*DM|SidLRR0 KH*eH6U;+Rl3}58{ delta 22 ecmZ3N@-TkGT*l3p7?YVchqD~l+$>@s#RLF$r3jq> diff --git a/test/kernel/src/settings_test.dart b/test/kernel/src/settings_test.dart index e8566b32..56d2d808 100644 --- a/test/kernel/src/settings_test.dart +++ b/test/kernel/src/settings_test.dart @@ -156,6 +156,23 @@ void main() { expect(store.get('app.list'), [1, 'two', null, false]); }); + test('YAML emitter handles nested lists and empty maps', () async { + // Nested list — forces _emitScalar's `v is List` recursive branch. + await store.set('app.nested', [ + [1, 2], + ['a', 'b'], + ]); + // Empty map under an app.* key — forces _emit's empty-map branch. + // Use a key whose value is itself a Map. + await store.set('app.empty', {}); + // Round-trip. + await store.load(); + expect(store.get('app.nested'), [ + [1, 2], + ['a', 'b'], + ]); + }); + test('load tolerates a malformed YAML file', () async { // Write garbage to the on-disk app settings, then load. final f = File('${tmp.path}/settings.yaml');