@pi-archimedes/core 2.0.0 → 2.1.0
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/README.md +51 -0
- package/package.json +1 -1
- package/src/config.test.ts +86 -0
- package/src/settings-io.test.ts +103 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/sections.test.ts +314 -0
- package/src/startup/version.test.ts +98 -0
- package/src/thinking/patch.test.ts +227 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/unindent.test.ts +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @pi-archimedes/core
|
|
2
|
+
|
|
3
|
+
The visual foundation and shared infrastructure for pi-archimedes.
|
|
4
|
+
|
|
5
|
+
Core is what you see first — the animated splash screen, the framed editor, and styled thinking blocks. It is also the invisible glue: an event bus that lets packages talk to each other, shared text and color utilities, and a settings system. Install it standalone for polished chrome, or let the meta package include it automatically.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
- **Animated splash screen** — configurable reveal animations (9 styles) that set the tone when Pi starts
|
|
10
|
+
- **Framed editor** — custom editor component with double-press quit guard
|
|
11
|
+
- **Styled thinking blocks** — configurable label text, color, and muted theme option; optional code block unindenting
|
|
12
|
+
- **Event bus** — shared pub/sub channel that lets packages communicate (subagent costs → footer, subagent questions → ask, etc.)
|
|
13
|
+
- **Shared utilities** — text truncation/width calculation, color helpers, config loading, settings I/O, and startup profiling
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@pi-archimedes/core
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install full meta package:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-archimedes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Core works automatically when Pi starts. It sets the session header, editor frame, and thinking block renderer automatically. There are no manual commands or tools to call.
|
|
30
|
+
|
|
31
|
+
For configuration:
|
|
32
|
+
- When using the meta package, run `/archimedes` to access the settings panel.
|
|
33
|
+
- For standalone installs, edit `archimedes.core` in `~/.pi/agent/settings.json`.
|
|
34
|
+
|
|
35
|
+
## Settings
|
|
36
|
+
|
|
37
|
+
Core reads configuration from the `archimedes.core` namespace in `~/.pi/agent/settings.json`.
|
|
38
|
+
|
|
39
|
+
| Setting | Type | Default | Description |
|
|
40
|
+
|---------|------|---------|-------------|
|
|
41
|
+
| `mutedTheme` | bool | `false` | Use subdued colors for thinking blocks |
|
|
42
|
+
| `codeUnindent` | bool | `true` | Remove common indentation from code blocks inside thinking sections |
|
|
43
|
+
| `labelText` | string | `Thinking...` | Custom prefix shown before thinking blocks |
|
|
44
|
+
| `labelColor` | string | `255,215,0` | RGB color for the thinking label |
|
|
45
|
+
| `animationStyle` | string | `vertical-up` | Splash animation style (9 options) |
|
|
46
|
+
|
|
47
|
+
## Integration
|
|
48
|
+
|
|
49
|
+
Core is auto-included by the `pi-archimedes` meta package. All other archimedes packages depend on `@pi-archimedes/core` for the event bus, chrome elements, and utilities. Standalone installation provides the visual chrome and bus without the other archimedes features.
|
|
50
|
+
|
|
51
|
+
← Back to [pi-archimedes](../../README.md)
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./settings-io.js", () => ({
|
|
4
|
+
loadConfig: vi.fn(),
|
|
5
|
+
saveConfig: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES } =
|
|
9
|
+
await import("./config.js");
|
|
10
|
+
const { loadConfig, saveConfig } = await import("./settings-io.js");
|
|
11
|
+
|
|
12
|
+
describe("loadCoreConfig", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("uses correct namespace", () => {
|
|
18
|
+
vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
|
|
19
|
+
loadCoreConfig();
|
|
20
|
+
expect(loadConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns default config when no settings exist", () => {
|
|
24
|
+
vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
|
|
25
|
+
const result = loadCoreConfig();
|
|
26
|
+
expect(result).toEqual({
|
|
27
|
+
mutedTheme: false,
|
|
28
|
+
codeUnindent: true,
|
|
29
|
+
labelText: "Thinking...",
|
|
30
|
+
labelColor: "255,215,0",
|
|
31
|
+
animationStyle: "vertical-up",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("passes through merged config from settings-io", () => {
|
|
36
|
+
const merged = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
|
|
37
|
+
vi.mocked(loadConfig).mockReturnValue(merged);
|
|
38
|
+
const result = loadCoreConfig();
|
|
39
|
+
expect(result).toEqual(merged);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("saveCoreConfig", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("saves with correct namespace", () => {
|
|
49
|
+
saveCoreConfig(DEFAULT_CORE_CONFIG);
|
|
50
|
+
expect(saveConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("passes config through unchanged", () => {
|
|
54
|
+
const config = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
|
|
55
|
+
saveCoreConfig(config);
|
|
56
|
+
expect(saveConfig).toHaveBeenCalledWith("archimedes.core", config);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("DEFAULT_CORE_CONFIG", () => {
|
|
61
|
+
it("has the expected shape", () => {
|
|
62
|
+
expect(DEFAULT_CORE_CONFIG).toEqual({
|
|
63
|
+
mutedTheme: false,
|
|
64
|
+
codeUnindent: true,
|
|
65
|
+
labelText: "Thinking...",
|
|
66
|
+
labelColor: "255,215,0",
|
|
67
|
+
animationStyle: "vertical-up",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("ANIMATION_STYLES", () => {
|
|
73
|
+
it("contains all expected styles", () => {
|
|
74
|
+
expect(ANIMATION_STYLES).toEqual([
|
|
75
|
+
"diagonal",
|
|
76
|
+
"top-right",
|
|
77
|
+
"bottom-left",
|
|
78
|
+
"bottom-right",
|
|
79
|
+
"center-out",
|
|
80
|
+
"wave",
|
|
81
|
+
"horizontal",
|
|
82
|
+
"vertical",
|
|
83
|
+
"vertical-up",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── hoisted: must use require() since vi.hoisted runs before imports ────────
|
|
4
|
+
|
|
5
|
+
const { tempDir, fs, join, tmpdir, randomUUID } = vi.hoisted(() => {
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const { join } = require("node:path");
|
|
8
|
+
const { tmpdir } = require("node:os");
|
|
9
|
+
const { randomUUID } = require("node:crypto");
|
|
10
|
+
const dir = join(tmpdir(), `settings-io-test-${randomUUID()}`);
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
return { tempDir: dir, fs, join, tmpdir, randomUUID };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
16
|
+
getAgentDir: () => tempDir,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Import after mocks are set up
|
|
20
|
+
const { loadConfig, saveConfig } = await import("./settings-io.js");
|
|
21
|
+
|
|
22
|
+
describe("loadConfig", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Clean up settings file before each test
|
|
25
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
26
|
+
if (fs.existsSync(settingsPath)) {
|
|
27
|
+
fs.unlinkSync(settingsPath);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
// Clean up temp dir artifacts
|
|
33
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
34
|
+
const tmpPath = settingsPath + ".tmp";
|
|
35
|
+
try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
|
|
36
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns defaults when settings missing", () => {
|
|
40
|
+
const result = loadConfig("test.ns", { foo: "bar", count: 42 });
|
|
41
|
+
expect(result).toEqual({ foo: "bar", count: 42 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("merges settings over defaults", () => {
|
|
45
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
settingsPath,
|
|
48
|
+
JSON.stringify({ "test.ns": { foo: "overridden" } }),
|
|
49
|
+
"utf-8",
|
|
50
|
+
);
|
|
51
|
+
const result = loadConfig("test.ns", { foo: "bar", count: 42 });
|
|
52
|
+
expect(result).toEqual({ foo: "overridden", count: 42 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns defaults on corrupt JSON", () => {
|
|
56
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
57
|
+
fs.writeFileSync(settingsPath, "{ invalid json }", "utf-8");
|
|
58
|
+
const result = loadConfig("test.ns", { foo: "bar", count: 42 });
|
|
59
|
+
expect(result).toEqual({ foo: "bar", count: 42 });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("saveConfig", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
66
|
+
if (fs.existsSync(settingsPath)) {
|
|
67
|
+
fs.unlinkSync(settingsPath);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
73
|
+
const tmpPath = settingsPath + ".tmp";
|
|
74
|
+
try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
|
|
75
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("writes atomically (tmp + rename)", () => {
|
|
79
|
+
saveConfig("test.ns", { foo: "bar" });
|
|
80
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
81
|
+
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
82
|
+
const tmpPath = settingsPath + ".tmp";
|
|
83
|
+
expect(fs.existsSync(tmpPath)).toBe(false);
|
|
84
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
85
|
+
expect(data["test.ns"]).toEqual({ foo: "bar" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("persists data for subsequent loads", () => {
|
|
89
|
+
saveConfig("test.ns", { foo: "bar", count: 42 });
|
|
90
|
+
const result = loadConfig("test.ns", { foo: "default", count: 0 });
|
|
91
|
+
expect(result).toEqual({ foo: "bar", count: 42 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("writes data correctly on success path", () => {
|
|
95
|
+
saveConfig("test.ns", { foo: "bar" });
|
|
96
|
+
const settingsPath = join(tempDir, "settings.json");
|
|
97
|
+
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
98
|
+
const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
99
|
+
expect(data["test.ns"]).toEqual({ foo: "bar" });
|
|
100
|
+
// No .tmp file should remain after successful rename
|
|
101
|
+
expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fc from "fast-check";
|
|
3
|
+
import { stripAnsi } from "../text.js";
|
|
4
|
+
|
|
5
|
+
// ── Helpers for dynamic imports with controlled TRUECOLOR ────────────────────
|
|
6
|
+
|
|
7
|
+
async function importLogoWithTruecolor(truecolor: boolean) {
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
|
|
10
|
+
// Set env before the module loads
|
|
11
|
+
const origColorterm = process.env.COLORTERM;
|
|
12
|
+
const origTerm = process.env.TERM;
|
|
13
|
+
const origTermProgram = process.env.TERM_PROGRAM;
|
|
14
|
+
const origWtSession = process.env.WT_SESSION;
|
|
15
|
+
|
|
16
|
+
if (truecolor) {
|
|
17
|
+
process.env.COLORTERM = "truecolor";
|
|
18
|
+
} else {
|
|
19
|
+
delete process.env.COLORTERM;
|
|
20
|
+
process.env.TERM = "xterm";
|
|
21
|
+
delete process.env.TERM_PROGRAM;
|
|
22
|
+
delete process.env.WT_SESSION;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mod = await import("./logo.js");
|
|
26
|
+
|
|
27
|
+
// Restore env
|
|
28
|
+
if (origColorterm === undefined) delete process.env.COLORTERM;
|
|
29
|
+
else process.env.COLORTERM = origColorterm;
|
|
30
|
+
if (origTerm === undefined) delete process.env.TERM;
|
|
31
|
+
else process.env.TERM = origTerm;
|
|
32
|
+
if (origTermProgram === undefined) delete process.env.TERM_PROGRAM;
|
|
33
|
+
else process.env.TERM_PROGRAM = origTermProgram;
|
|
34
|
+
if (origWtSession === undefined) delete process.env.WT_SESSION;
|
|
35
|
+
else process.env.WT_SESSION = origWtSession;
|
|
36
|
+
|
|
37
|
+
return mod;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── LOGO structure (static, no env dependency) ──────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("LOGO structure", () => {
|
|
43
|
+
it("has 8 rows", async () => {
|
|
44
|
+
const mod = await importLogoWithTruecolor(false);
|
|
45
|
+
expect(mod.LOGO.length).toBe(8);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("each row has 16 characters", async () => {
|
|
49
|
+
const mod = await importLogoWithTruecolor(false);
|
|
50
|
+
for (const row of mod.LOGO) {
|
|
51
|
+
expect(row.length).toBe(16);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("first row is 12 blocks + 4 spaces", async () => {
|
|
56
|
+
const mod = await importLogoWithTruecolor(false);
|
|
57
|
+
expect(mod.LOGO[0]).toBe("████████████ ");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("last row is 4 blocks + 6 spaces + 4 blocks", async () => {
|
|
61
|
+
const mod = await importLogoWithTruecolor(false);
|
|
62
|
+
expect(mod.LOGO[7]).toBe("████ ████");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("LOGO contains only block chars and spaces", async () => {
|
|
66
|
+
const mod = await importLogoWithTruecolor(false);
|
|
67
|
+
for (const row of mod.LOGO) {
|
|
68
|
+
for (const ch of row) {
|
|
69
|
+
expect(ch === " " || ch === "█").toBe(true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Animation constants ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe("animation constants", () => {
|
|
78
|
+
it("CHAR_FADE_FRAMES is 22", async () => {
|
|
79
|
+
const mod = await importLogoWithTruecolor(false);
|
|
80
|
+
expect(mod.CHAR_FADE_FRAMES).toBe(22);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("LOGO_SETTLE_FRAME is 90", async () => {
|
|
84
|
+
const mod = await importLogoWithTruecolor(false);
|
|
85
|
+
expect(mod.LOGO_SETTLE_FRAME).toBe(90);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("LOGO_PAD is 0", async () => {
|
|
89
|
+
const mod = await importLogoWithTruecolor(false);
|
|
90
|
+
expect(mod.LOGO_PAD).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("LOGO_GAP is 4", async () => {
|
|
94
|
+
const mod = await importLogoWithTruecolor(false);
|
|
95
|
+
expect(mod.LOGO_GAP).toBe(4);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── TRUECOLOR detection ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("TRUECOLOR", () => {
|
|
102
|
+
it("is true when COLORTERM contains truecolor", async () => {
|
|
103
|
+
const mod = await importLogoWithTruecolor(true);
|
|
104
|
+
expect(mod.TRUECOLOR).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is false when no truecolor env vars set", async () => {
|
|
108
|
+
const mod = await importLogoWithTruecolor(false);
|
|
109
|
+
expect(mod.TRUECOLOR).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── getShinedLogo — non-truecolor ────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("getShinedLogo (non-truecolor)", () => {
|
|
116
|
+
let mod: typeof import("./logo.js");
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
mod = await importLogoWithTruecolor(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns LOGO unchanged when TRUECOLOR is false", () => {
|
|
123
|
+
const result = mod.getShinedLogo(0, "wave");
|
|
124
|
+
expect(result).toBe(mod.LOGO);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns LOGO at any frame when TRUECOLOR is false", () => {
|
|
128
|
+
expect(mod.getShinedLogo(999, "wave")).toBe(mod.LOGO);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns 8 rows regardless of frame", () => {
|
|
132
|
+
expect(mod.getShinedLogo(50, "diagonal").length).toBe(8);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── getShinedLogo — truecolor ────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("getShinedLogo (truecolor)", () => {
|
|
139
|
+
let mod: typeof import("./logo.js");
|
|
140
|
+
|
|
141
|
+
beforeEach(async () => {
|
|
142
|
+
mod = await importLogoWithTruecolor(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns 8 rows", () => {
|
|
146
|
+
expect(mod.getShinedLogo(0, "wave").length).toBe(8);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns different output than LOGO when TRUECOLOR is true", () => {
|
|
150
|
+
const result = mod.getShinedLogo(50, "wave");
|
|
151
|
+
expect(result).not.toBe(mod.LOGO);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("early frames show spaces for not-yet-revealed chars", () => {
|
|
155
|
+
const result = mod.getShinedLogo(0, "vertical");
|
|
156
|
+
// At frame 0, nothing should be revealed yet
|
|
157
|
+
for (const row of result) {
|
|
158
|
+
const stripped = stripAnsi(row);
|
|
159
|
+
expect(stripped).toMatch(/^[\s]*$/);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("late frames show all characters revealed", () => {
|
|
164
|
+
const result = mod.getShinedLogo(200, "vertical");
|
|
165
|
+
for (let i = 0; i < result.length; i++) {
|
|
166
|
+
const stripped = stripAnsi(result[i]!);
|
|
167
|
+
const expected = stripAnsi(mod.LOGO[i]!);
|
|
168
|
+
expect(stripped).toBe(expected);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("output contains ANSI gray escapes", () => {
|
|
173
|
+
const result = mod.getShinedLogo(50, "wave");
|
|
174
|
+
const joined = result.join("\n");
|
|
175
|
+
expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("default style is wave", () => {
|
|
179
|
+
const result1 = mod.getShinedLogo(50);
|
|
180
|
+
const result2 = mod.getShinedLogo(50, "wave");
|
|
181
|
+
expect(result1).toEqual(result2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── All animation styles ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe("animation styles", () => {
|
|
188
|
+
const styles = [
|
|
189
|
+
"diagonal",
|
|
190
|
+
"top-right",
|
|
191
|
+
"bottom-left",
|
|
192
|
+
"bottom-right",
|
|
193
|
+
"center-out",
|
|
194
|
+
"wave",
|
|
195
|
+
"horizontal",
|
|
196
|
+
"vertical",
|
|
197
|
+
"vertical-up",
|
|
198
|
+
] as const;
|
|
199
|
+
|
|
200
|
+
it.each(styles)("style '%s' produces valid reveal times", async (style) => {
|
|
201
|
+
const mod = await importLogoWithTruecolor(true);
|
|
202
|
+
const result = mod.getShinedLogo(100, style);
|
|
203
|
+
expect(result.length).toBe(8);
|
|
204
|
+
// Each row should be a string
|
|
205
|
+
for (const row of result) {
|
|
206
|
+
expect(typeof row).toBe("string");
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each(styles)("style '%s' fully reveals at high frame count", async (style) => {
|
|
211
|
+
const mod = await importLogoWithTruecolor(true);
|
|
212
|
+
const result = mod.getShinedLogo(500, style);
|
|
213
|
+
for (let i = 0; i < result.length; i++) {
|
|
214
|
+
const stripped = stripAnsi(result[i]!);
|
|
215
|
+
const expected = stripAnsi(mod.LOGO[i]!);
|
|
216
|
+
expect(stripped).toBe(expected);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── Properties ───────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("properties", () => {
|
|
224
|
+
it("getShinedLogo always returns 8 rows (truecolor)", async () => {
|
|
225
|
+
const mod = await importLogoWithTruecolor(true);
|
|
226
|
+
fc.assert(
|
|
227
|
+
fc.property(fc.nat(1000), n => {
|
|
228
|
+
return mod.getShinedLogo(n, "wave").length === 8;
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("each row visible width equals 16 after stripping ANSI (truecolor)", async () => {
|
|
234
|
+
const mod = await importLogoWithTruecolor(true);
|
|
235
|
+
fc.assert(
|
|
236
|
+
fc.property(fc.nat(500), n => {
|
|
237
|
+
const result = mod.getShinedLogo(n, "diagonal");
|
|
238
|
+
for (const row of result) {
|
|
239
|
+
const stripped = stripAnsi(row);
|
|
240
|
+
// stripAnsi trims, so we check the raw row length instead
|
|
241
|
+
// The actual row length (including ANSI) may vary, but the
|
|
242
|
+
// visible content should always be 16 chars
|
|
243
|
+
// Since stripAnsi trims trailing spaces, check untrimmed
|
|
244
|
+
const raw = row.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
245
|
+
if (raw.length !== 16) return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("getShinedLogo returns 8 rows (non-truecolor)", async () => {
|
|
253
|
+
const mod = await importLogoWithTruecolor(false);
|
|
254
|
+
fc.assert(
|
|
255
|
+
fc.property(fc.nat(1000), n => {
|
|
256
|
+
return mod.getShinedLogo(n, "wave").length === 8;
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
});
|