claudeup 4.37.0 → 4.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +531 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
COMMUNITY_STYLES,
|
|
8
|
+
resolveCommunityStyle,
|
|
9
|
+
} from "../data/community-styles.js";
|
|
10
|
+
import {
|
|
11
|
+
type ImportedStyle,
|
|
12
|
+
type StyleDeclaration,
|
|
13
|
+
type StylePreset,
|
|
14
|
+
applyStyles,
|
|
15
|
+
computeStyleStatus,
|
|
16
|
+
loadStyles,
|
|
17
|
+
readStyleDeclaration,
|
|
18
|
+
resolveDeclaration,
|
|
19
|
+
styleHash,
|
|
20
|
+
} from "../services/styles-manager.js";
|
|
21
|
+
|
|
22
|
+
function preset(
|
|
23
|
+
name: string,
|
|
24
|
+
body = "rules",
|
|
25
|
+
axis: "verbosity" | "modifier" = "modifier",
|
|
26
|
+
): StylePreset {
|
|
27
|
+
return {
|
|
28
|
+
kind: "preset",
|
|
29
|
+
id: name,
|
|
30
|
+
name,
|
|
31
|
+
displayName: name,
|
|
32
|
+
axis,
|
|
33
|
+
summary: "",
|
|
34
|
+
conflicts: [],
|
|
35
|
+
template: false,
|
|
36
|
+
body,
|
|
37
|
+
path: `/styles/${name}.md`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function imported(name: string, body = "imported rules"): ImportedStyle {
|
|
42
|
+
return {
|
|
43
|
+
kind: "imported",
|
|
44
|
+
id: `project:${name}`,
|
|
45
|
+
name,
|
|
46
|
+
displayName: name,
|
|
47
|
+
scope: "project",
|
|
48
|
+
origin: "team",
|
|
49
|
+
managed: false,
|
|
50
|
+
description: "",
|
|
51
|
+
updatedAt: null,
|
|
52
|
+
capturedFrom: null,
|
|
53
|
+
community: null,
|
|
54
|
+
body,
|
|
55
|
+
path: `/p/.claude/output-styles/${name}.md`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function declaration(over: Partial<StyleDeclaration> = {}): StyleDeclaration {
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
presets: ["direct"],
|
|
63
|
+
imports: [],
|
|
64
|
+
hash: "sha256:whatever",
|
|
65
|
+
updatedAt: "2026-08-18T00:00:00.000Z",
|
|
66
|
+
...over,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("styleHash", () => {
|
|
71
|
+
test("is stable for the same sources", () => {
|
|
72
|
+
const sources = [preset("direct"), preset("no-slop")];
|
|
73
|
+
expect(styleHash(sources)).toBe(styleHash([...sources]));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("changes when a preset's BODY changes, not just its name", () => {
|
|
77
|
+
// The case that matters: a plugin update rewrites a preset. The names the
|
|
78
|
+
// project declared are unchanged, but what Claude Code is told is not.
|
|
79
|
+
// Hashing names alone would call this in-sync.
|
|
80
|
+
expect(styleHash([preset("direct", "old text")])).not.toBe(
|
|
81
|
+
styleHash([preset("direct", "new text")]),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("changes when the set changes", () => {
|
|
86
|
+
expect(styleHash([preset("direct")])).not.toBe(
|
|
87
|
+
styleHash([preset("direct"), preset("no-slop")]),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("changes when the ORDER changes", () => {
|
|
92
|
+
// Order is meaningful — imports first, then verbosity, then modifiers, so
|
|
93
|
+
// a later rule refines an earlier one.
|
|
94
|
+
expect(styleHash([preset("a"), preset("b")])).not.toBe(
|
|
95
|
+
styleHash([preset("b"), preset("a")]),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("cannot be spoofed by moving text across a boundary", () => {
|
|
100
|
+
// Naive concatenation would make these collide.
|
|
101
|
+
expect(styleHash([preset("ab", "c")])).not.toBe(
|
|
102
|
+
styleHash([preset("a", "bc")]),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("resolveDeclaration", () => {
|
|
108
|
+
test("returns sources in composition order", () => {
|
|
109
|
+
const { sources, missing } = resolveDeclaration(
|
|
110
|
+
declaration({
|
|
111
|
+
presets: ["no-slop", "direct"],
|
|
112
|
+
imports: ["project:house"],
|
|
113
|
+
}),
|
|
114
|
+
[preset("direct", "d", "verbosity"), preset("no-slop", "n")],
|
|
115
|
+
[imported("house")],
|
|
116
|
+
);
|
|
117
|
+
expect(sources.map((s) => s.name)).toEqual(["house", "direct", "no-slop"]);
|
|
118
|
+
expect(missing).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("reports names that no longer resolve", () => {
|
|
122
|
+
// A teammate committed a style this machine does not have — saying so is
|
|
123
|
+
// more useful than silently composing a smaller set.
|
|
124
|
+
const { sources, missing } = resolveDeclaration(
|
|
125
|
+
declaration({ presets: ["direct", "gone"], imports: ["project:absent"] }),
|
|
126
|
+
[preset("direct")],
|
|
127
|
+
[],
|
|
128
|
+
);
|
|
129
|
+
expect(sources.map((s) => s.name)).toEqual(["direct"]);
|
|
130
|
+
expect(missing).toEqual(["gone", "project:absent"]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("computeStyleStatus", () => {
|
|
135
|
+
const presets = [preset("direct", "d")];
|
|
136
|
+
|
|
137
|
+
test("undeclared when the project has committed nothing", () => {
|
|
138
|
+
expect(
|
|
139
|
+
computeStyleStatus({
|
|
140
|
+
declaration: null,
|
|
141
|
+
presets,
|
|
142
|
+
imports: [],
|
|
143
|
+
localHash: null,
|
|
144
|
+
isActive: false,
|
|
145
|
+
}).state,
|
|
146
|
+
).toBe("undeclared");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("not-applied when the style is declared but not active here", () => {
|
|
150
|
+
// The fresh-clone case.
|
|
151
|
+
expect(
|
|
152
|
+
computeStyleStatus({
|
|
153
|
+
declaration: declaration(),
|
|
154
|
+
presets,
|
|
155
|
+
imports: [],
|
|
156
|
+
localHash: null,
|
|
157
|
+
isActive: false,
|
|
158
|
+
}).state,
|
|
159
|
+
).toBe("not-applied");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("not-applied when active but the file records no hash", () => {
|
|
163
|
+
// A file written before hashing existed. Unknown must read as out of
|
|
164
|
+
// date, never as in sync.
|
|
165
|
+
expect(
|
|
166
|
+
computeStyleStatus({
|
|
167
|
+
declaration: declaration(),
|
|
168
|
+
presets,
|
|
169
|
+
imports: [],
|
|
170
|
+
localHash: null,
|
|
171
|
+
isActive: true,
|
|
172
|
+
}).state,
|
|
173
|
+
).toBe("not-applied");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("in-sync when the local hash matches what the declaration composes to now", () => {
|
|
177
|
+
const expected = styleHash([preset("direct", "d")]);
|
|
178
|
+
expect(
|
|
179
|
+
computeStyleStatus({
|
|
180
|
+
declaration: declaration(),
|
|
181
|
+
presets,
|
|
182
|
+
imports: [],
|
|
183
|
+
localHash: expected,
|
|
184
|
+
isActive: true,
|
|
185
|
+
}).state,
|
|
186
|
+
).toBe("in-sync");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("stale when the preset text changed underneath an applied style", () => {
|
|
190
|
+
// Same declaration, same names, different rules — exactly the drift the
|
|
191
|
+
// hash exists to catch.
|
|
192
|
+
const appliedEarlier = styleHash([preset("direct", "OLD")]);
|
|
193
|
+
const status = computeStyleStatus({
|
|
194
|
+
declaration: declaration(),
|
|
195
|
+
presets: [preset("direct", "NEW")],
|
|
196
|
+
imports: [],
|
|
197
|
+
localHash: appliedEarlier,
|
|
198
|
+
isActive: true,
|
|
199
|
+
});
|
|
200
|
+
expect(status.state).toBe("stale");
|
|
201
|
+
expect(status.detail).toMatch(/re-apply/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("carries the missing names through", () => {
|
|
205
|
+
const status = computeStyleStatus({
|
|
206
|
+
declaration: declaration({ presets: ["direct", "gone"] }),
|
|
207
|
+
presets,
|
|
208
|
+
imports: [],
|
|
209
|
+
localHash: null,
|
|
210
|
+
isActive: false,
|
|
211
|
+
});
|
|
212
|
+
expect(status.missing).toEqual(["gone"]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ─── Phase 5: declaration portability ─────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A community style as it exists on a machine that HAS fetched it.
|
|
220
|
+
*
|
|
221
|
+
* `name` is the coordinate id, `id` prefixes it with the scope — the same split
|
|
222
|
+
* the `builtin-` handling uses, and what lets a declaration name a style that is
|
|
223
|
+
* not on this machine yet.
|
|
224
|
+
*/
|
|
225
|
+
function communityStyle(coordinate: string, body = "third-party rules") {
|
|
226
|
+
return {
|
|
227
|
+
...imported(coordinate, body),
|
|
228
|
+
id: `community:${coordinate}`,
|
|
229
|
+
name: coordinate,
|
|
230
|
+
displayName: coordinate,
|
|
231
|
+
scope: "community" as const,
|
|
232
|
+
origin: "community" as const,
|
|
233
|
+
managed: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
describe("a declaration naming a community style this machine lacks", () => {
|
|
238
|
+
const declared = declaration({
|
|
239
|
+
presets: [],
|
|
240
|
+
imports: ["community:attention-span--spartan"],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("resolves as FETCHABLE rather than bare missing", () => {
|
|
244
|
+
// The feature's best argument, and it collapses without this: a teammate
|
|
245
|
+
// clones a repo whose .claude/style.json names a community style and has
|
|
246
|
+
// fetched nothing. Reporting a bare "missing" would be an unactionable
|
|
247
|
+
// error for a situation that is one keypress from resolved.
|
|
248
|
+
const { missing, fetchable } = resolveDeclaration(declared, [], []);
|
|
249
|
+
expect(fetchable).toEqual(["community:attention-span--spartan"]);
|
|
250
|
+
expect(missing).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("the two lists are disjoint — a dead end is not the same as a fetch", () => {
|
|
254
|
+
const { missing, fetchable } = resolveDeclaration(
|
|
255
|
+
declaration({
|
|
256
|
+
presets: ["gone"],
|
|
257
|
+
imports: [
|
|
258
|
+
"community:attention-span--spartan",
|
|
259
|
+
"community:nobody--nothing",
|
|
260
|
+
"project:absent",
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
[],
|
|
264
|
+
[],
|
|
265
|
+
);
|
|
266
|
+
expect(fetchable).toEqual(["community:attention-span--spartan"]);
|
|
267
|
+
// An id the registry has never heard of is NOT fetchable, however it is
|
|
268
|
+
// spelled — the prefix is not a licence to invent a coordinate.
|
|
269
|
+
expect(missing).toEqual([
|
|
270
|
+
"gone",
|
|
271
|
+
"community:nobody--nothing",
|
|
272
|
+
"project:absent",
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("the status detail says what to press, and the count", () => {
|
|
277
|
+
const one = computeStyleStatus({
|
|
278
|
+
declaration: declared,
|
|
279
|
+
presets: [],
|
|
280
|
+
imports: [],
|
|
281
|
+
localHash: null,
|
|
282
|
+
isActive: false,
|
|
283
|
+
});
|
|
284
|
+
expect(one.detail).toBe("project style needs 1 community style — press f");
|
|
285
|
+
|
|
286
|
+
const two = computeStyleStatus({
|
|
287
|
+
declaration: declaration({
|
|
288
|
+
presets: [],
|
|
289
|
+
imports: [
|
|
290
|
+
"community:attention-span--spartan",
|
|
291
|
+
"community:attention-span--rundown",
|
|
292
|
+
],
|
|
293
|
+
}),
|
|
294
|
+
presets: [],
|
|
295
|
+
imports: [],
|
|
296
|
+
localHash: null,
|
|
297
|
+
isActive: false,
|
|
298
|
+
});
|
|
299
|
+
expect(two.detail).toBe("project style needs 2 community styles — press f");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("never reports in-sync while a declared style is still un-fetched", () => {
|
|
303
|
+
// The hashes agree because the un-fetched style was left out of BOTH
|
|
304
|
+
// sides. That is the composition matching itself, not the declaration.
|
|
305
|
+
const localHash = styleHash([]);
|
|
306
|
+
const status = computeStyleStatus({
|
|
307
|
+
declaration: declared,
|
|
308
|
+
presets: [],
|
|
309
|
+
imports: [],
|
|
310
|
+
localHash,
|
|
311
|
+
isActive: true,
|
|
312
|
+
});
|
|
313
|
+
expect(status.state).toBe("stale");
|
|
314
|
+
expect(status.detail).toMatch(/press f/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("once fetched it resolves normally and composes like any import", () => {
|
|
318
|
+
const style = communityStyle("attention-span--spartan");
|
|
319
|
+
const { sources, missing, fetchable } = resolveDeclaration(
|
|
320
|
+
declared,
|
|
321
|
+
[],
|
|
322
|
+
[style],
|
|
323
|
+
);
|
|
324
|
+
expect(sources.map((s) => s.id)).toEqual([
|
|
325
|
+
"community:attention-span--spartan",
|
|
326
|
+
]);
|
|
327
|
+
expect(missing).toEqual([]);
|
|
328
|
+
expect(fetchable).toEqual([]);
|
|
329
|
+
|
|
330
|
+
expect(
|
|
331
|
+
computeStyleStatus({
|
|
332
|
+
declaration: declared,
|
|
333
|
+
presets: [],
|
|
334
|
+
imports: [style],
|
|
335
|
+
localHash: styleHash(sources),
|
|
336
|
+
isActive: true,
|
|
337
|
+
}).state,
|
|
338
|
+
).toBe("in-sync");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("the registry rule: entries are retired, never deleted", () => {
|
|
343
|
+
/**
|
|
344
|
+
* Every coordinate shipped in v1.
|
|
345
|
+
*
|
|
346
|
+
* A deleted entry breaks a committed `.claude/style.json` on every
|
|
347
|
+
* teammate's machine — the id stops resolving, so it stops being FETCHABLE
|
|
348
|
+
* and becomes an unactionable "missing". Retiring one instead
|
|
349
|
+
* (`retired: true`) drops it from the offers list while keeping every
|
|
350
|
+
* declaration that names it working.
|
|
351
|
+
*
|
|
352
|
+
* If this fails, do not delete the id from the list. Mark the registry entry
|
|
353
|
+
* `retired: true` and leave it resolvable.
|
|
354
|
+
*/
|
|
355
|
+
const SHIPPED_IDS = [
|
|
356
|
+
"attention-span--attention-kind",
|
|
357
|
+
"attention-span--rundown",
|
|
358
|
+
"attention-span--spartan",
|
|
359
|
+
"claude-mods--executive",
|
|
360
|
+
"claude-mods--mentor",
|
|
361
|
+
"claude-mods--noir",
|
|
362
|
+
"claude-mods--pair",
|
|
363
|
+
"claude-mods--roast",
|
|
364
|
+
"lej-output-fixer--bottom-line-first",
|
|
365
|
+
"lej-output-fixer--scannable",
|
|
366
|
+
"lej-output-fixer--walk-me-through-it",
|
|
367
|
+
"hesreallyhim--door-to-door-vim-salesman",
|
|
368
|
+
"hesreallyhim--existentialist-poet",
|
|
369
|
+
"hesreallyhim--haiku-helper",
|
|
370
|
+
"hesreallyhim--tabloid-journalist",
|
|
371
|
+
"hesreallyhim--technical-evangelist",
|
|
372
|
+
"hesreallyhim--zen-master",
|
|
373
|
+
"nattergabriel--challenger",
|
|
374
|
+
"nattergabriel--paranoid",
|
|
375
|
+
"nattergabriel--socratic",
|
|
376
|
+
"nattergabriel--tdd",
|
|
377
|
+
"nattergabriel--think-aloud",
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
test("every id ever shipped still resolves against the registry", () => {
|
|
381
|
+
const unresolvable = SHIPPED_IDS.filter((id) => !resolveCommunityStyle(id));
|
|
382
|
+
expect(unresolvable).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("every shipped id is still reported as fetchable by a declaration", () => {
|
|
386
|
+
// The consequence that actually matters. Resolving is the mechanism;
|
|
387
|
+
// staying fetchable is the promise made to a teammate's checkout.
|
|
388
|
+
const { fetchable } = resolveDeclaration(
|
|
389
|
+
declaration({
|
|
390
|
+
presets: [],
|
|
391
|
+
imports: SHIPPED_IDS.map((id) => `community:${id}`),
|
|
392
|
+
}),
|
|
393
|
+
[],
|
|
394
|
+
[],
|
|
395
|
+
);
|
|
396
|
+
expect(fetchable).toHaveLength(SHIPPED_IDS.length);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("a retired entry still resolves — retirement hides it, it does not delete it", () => {
|
|
400
|
+
const retired = COMMUNITY_STYLES.filter((style) => style.retired);
|
|
401
|
+
for (const style of retired) {
|
|
402
|
+
expect(resolveCommunityStyle(style.id), style.id).not.toBeNull();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("declaration round-trip", () => {
|
|
408
|
+
let dir: string;
|
|
409
|
+
|
|
410
|
+
beforeEach(async () => {
|
|
411
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-sync-"));
|
|
412
|
+
});
|
|
413
|
+
afterEach(async () => {
|
|
414
|
+
await rm(dir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("apply writes a declaration that commits with the project", async () => {
|
|
418
|
+
const result = await applyStyles({
|
|
419
|
+
projectPath: dir,
|
|
420
|
+
presets: [preset("direct", "d", "verbosity")],
|
|
421
|
+
imports: [],
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
expect(result.declarationPath).toBe(join(dir, ".claude", "style.json"));
|
|
425
|
+
const written = await readStyleDeclaration(dir);
|
|
426
|
+
expect(written?.presets).toEqual(["direct"]);
|
|
427
|
+
expect(written?.hash).toBe(result.hash);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a freshly applied project reads back as in-sync", async () => {
|
|
431
|
+
await applyStyles({
|
|
432
|
+
projectPath: dir,
|
|
433
|
+
presets: [preset("direct", "d", "verbosity")],
|
|
434
|
+
imports: [],
|
|
435
|
+
});
|
|
436
|
+
// loadStyles resolves against the INSTALLED plugin, which the sandbox has
|
|
437
|
+
// no copy of, so assert on the recorded hash instead of the state.
|
|
438
|
+
const snapshot = await loadStyles(dir, { home: join(dir, "home") });
|
|
439
|
+
expect(snapshot.declaration?.presets).toEqual(["direct"]);
|
|
440
|
+
expect(snapshot.applied?.hash).toBe(snapshot.declaration?.hash);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a corrupt declaration reads as none rather than crashing", async () => {
|
|
444
|
+
await fs.outputFile(join(dir, ".claude", "style.json"), "{ not json");
|
|
445
|
+
expect(await readStyleDeclaration(dir)).toBeNull();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a declaration missing its arrays reads as none", async () => {
|
|
449
|
+
await fs.outputJson(join(dir, ".claude", "style.json"), { version: 1 });
|
|
450
|
+
expect(await readStyleDeclaration(dir)).toBeNull();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { TABS, barWidth, layoutTabs } from "../ui/components/TabBar.js";
|
|
3
|
+
|
|
4
|
+
const FULL = TABS.map((t) => `${t.key}:${t.label}`);
|
|
5
|
+
const FULL_WIDTH = barWidth(FULL);
|
|
6
|
+
|
|
7
|
+
describe("tab bar layout", () => {
|
|
8
|
+
test("shows every label when the bar fits", () => {
|
|
9
|
+
expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual(FULL);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("drops inactive labels when the bar does not fit", () => {
|
|
13
|
+
// A 94-column pane leaves ~90 usable, and nine full tabs need ~99 — the
|
|
14
|
+
// width at which OpenTUI clipped each cell into a meaningless stub.
|
|
15
|
+
const texts = layoutTabs(TABS, "styles", 90);
|
|
16
|
+
expect(texts).toContain("9:Styles");
|
|
17
|
+
expect(texts).toContain("1");
|
|
18
|
+
expect(texts).not.toContain("1:Plugins");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the compact bar actually fits a narrow pane", () => {
|
|
22
|
+
// The point of compacting is that the result fits. If it did not, the
|
|
23
|
+
// fallback would just be a differently-truncated mess.
|
|
24
|
+
expect(barWidth(layoutTabs(TABS, "styles", 90))).toBeLessThanOrEqual(90);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("keeps the label of whichever tab is active", () => {
|
|
28
|
+
for (const tab of TABS) {
|
|
29
|
+
const texts = layoutTabs(TABS, tab.screen, 40);
|
|
30
|
+
expect(texts).toContain(`${tab.key}:${tab.label}`);
|
|
31
|
+
// Exactly one tab keeps its label.
|
|
32
|
+
expect(texts.filter((t) => t.includes(":"))).toHaveLength(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("compacting never changes how many tabs are drawn", () => {
|
|
37
|
+
expect(layoutTabs(TABS, "plugins", 20)).toHaveLength(TABS.length);
|
|
38
|
+
expect(layoutTabs(TABS, "plugins", 10_000)).toHaveLength(TABS.length);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("barWidth counts padding and separators", () => {
|
|
42
|
+
// " a " + "│" + " bb " = 3 + 1 + 4
|
|
43
|
+
expect(barWidth(["a", "bb"])).toBe(8);
|
|
44
|
+
expect(barWidth([])).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("every tab key is a digit the global handler binds", () => {
|
|
48
|
+
// The compact bar shows only the number, so the number must be the key
|
|
49
|
+
// that actually navigates.
|
|
50
|
+
expect(TABS.map((t) => t.key)).toEqual([
|
|
51
|
+
"1",
|
|
52
|
+
"2",
|
|
53
|
+
"3",
|
|
54
|
+
"4",
|
|
55
|
+
"5",
|
|
56
|
+
"6",
|
|
57
|
+
"7",
|
|
58
|
+
"8",
|
|
59
|
+
"9",
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
});
|