@gustavobrunodev/ai-tools 1.0.1 → 1.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/CHANGELOG.md +0 -0
- package/jest.config.ts +26 -0
- package/package.json +1 -1
- package/project.json +74 -0
- package/src/app.tsx +56 -0
- package/src/atoms/deprecatedSkills.ts +11 -0
- package/src/atoms/environmentCheck.ts +51 -0
- package/src/atoms/installedSkills.ts +36 -0
- package/src/atoms/wizard.ts +6 -0
- package/src/cli/audit.ts +21 -0
- package/src/cli/cache.ts +28 -0
- package/src/cli/install.ts +93 -0
- package/src/cli/list.ts +41 -0
- package/src/cli/remove.ts +70 -0
- package/src/cli/update.ts +107 -0
- package/src/components/AnimatedTransition.tsx +42 -0
- package/src/components/AuditLogViewer.tsx +85 -0
- package/src/components/CategoryHeader.tsx +39 -0
- package/src/components/ConfirmPrompt.tsx +34 -0
- package/src/components/FooterBar.tsx +36 -0
- package/src/components/Header.tsx +97 -0
- package/src/components/InstallResults.tsx +110 -0
- package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
- package/src/components/MultiSelectPrompt.tsx +219 -0
- package/src/components/SearchInput.tsx +36 -0
- package/src/components/SelectPrompt.tsx +108 -0
- package/src/components/SkillCard.tsx +74 -0
- package/src/components/SkillDetailPanel.tsx +233 -0
- package/src/components/StatusBadge.tsx +45 -0
- package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
- package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
- package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
- package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
- package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/__tests__/useConfig.test.ts +242 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAgents.ts +28 -0
- package/src/hooks/useConfig.ts +114 -0
- package/src/hooks/useFilter.ts +31 -0
- package/src/hooks/useInstaller.ts +39 -0
- package/src/hooks/useKeyboardNav.ts +39 -0
- package/src/hooks/useKonamiCode.ts +48 -0
- package/src/hooks/useRemover.ts +59 -0
- package/src/hooks/useSkillContent.ts +67 -0
- package/src/hooks/useSkills.ts +38 -0
- package/src/hooks/useWizardStep.ts +19 -0
- package/src/index.ts +129 -0
- package/src/services/__tests__/audit-log.spec.ts +220 -0
- package/src/services/__tests__/badge-format.test.ts +102 -0
- package/src/services/__tests__/category-colors.test.ts +253 -0
- package/src/services/__tests__/config.test.ts +184 -0
- package/src/services/__tests__/installer.security.spec.ts +151 -0
- package/src/services/__tests__/lockfile.security.spec.ts +132 -0
- package/src/services/__tests__/markdown-parser.spec.ts +185 -0
- package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
- package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
- package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
- package/src/services/__tests__/update-cache.test.ts +215 -0
- package/src/services/agents.ts +42 -0
- package/src/services/audio-player.ts +55 -0
- package/src/services/audit-log.ts +69 -0
- package/src/services/badge-format.ts +4 -0
- package/src/services/categories.ts +176 -0
- package/src/services/category-colors.ts +19 -0
- package/src/services/config.ts +84 -0
- package/src/services/github-contributors.ts +56 -0
- package/src/services/global-path.ts +20 -0
- package/src/services/index.ts +21 -0
- package/src/services/installer.ts +371 -0
- package/src/services/lockfile.ts +177 -0
- package/src/services/markdown-parser.ts +108 -0
- package/src/services/package-info.ts +19 -0
- package/src/services/project-root.ts +18 -0
- package/src/services/registry.ts +382 -0
- package/src/services/skills-provider.ts +169 -0
- package/src/services/terminal-dimensions.ts +18 -0
- package/src/services/update-cache.ts +65 -0
- package/src/services/update-check.ts +26 -0
- package/src/theme/colors.ts +24 -0
- package/src/theme/index.ts +2 -0
- package/src/theme/symbols.ts +22 -0
- package/src/types.ts +38 -0
- package/src/utils/constants.ts +49 -0
- package/src/utils/paths.ts +52 -0
- package/src/views/ActionSelector.tsx +45 -0
- package/src/views/AgentSelector.tsx +105 -0
- package/src/views/CreditsView.tsx +332 -0
- package/src/views/InstallConfig.tsx +162 -0
- package/src/views/InstallWizard.tsx +181 -0
- package/src/views/ListView.tsx +41 -0
- package/src/views/RemoveWizard.tsx +237 -0
- package/src/views/SkillBrowser.tsx +504 -0
- package/src/views/UpdateView.tsx +272 -0
- package/src/views/arcade/ArcadeMenu.tsx +89 -0
- package/src/views/arcade/VibeInvaders.tsx +339 -0
- package/src/views/arcade/index.ts +2 -0
- package/src/views/index.ts +11 -0
- package/tsconfig.json +19 -0
- package/tsconfig.spec.json +25 -0
- package/LICENSE +0 -26
- package/README.md +0 -257
- package/index.js +0 -12
- package/index.js.map +0 -7
- /package/{assets → src/assets}/chiptune.mp3 +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
import { colors } from '../theme/colors'
|
|
4
|
+
import { symbols } from '../theme/symbols'
|
|
5
|
+
|
|
6
|
+
export type StatusType = 'installed' | 'update' | 'new' | 'deprecated'
|
|
7
|
+
|
|
8
|
+
export interface StatusBadgeProps {
|
|
9
|
+
status: StatusType
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const badgeConfig = {
|
|
13
|
+
installed: { icon: symbols.check, label: 'installed', color: colors.success, bg: '#052e16' },
|
|
14
|
+
update: { icon: symbols.arrowUp, label: 'update', color: colors.warning, bg: '#422006' },
|
|
15
|
+
new: { icon: symbols.sparkle, label: 'new', color: colors.accent, bg: '#083344' },
|
|
16
|
+
deprecated: { icon: symbols.warning, label: 'deprecated', color: colors.warning, bg: '#422006' },
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export function StatusBadge({ status }: StatusBadgeProps) {
|
|
20
|
+
const config = badgeConfig[status]
|
|
21
|
+
if (!config) return null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box>
|
|
25
|
+
<Text backgroundColor={config.bg} color={config.color}>
|
|
26
|
+
{' '}
|
|
27
|
+
{config.icon} {config.label}{' '}
|
|
28
|
+
</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgentBadgeProps {
|
|
34
|
+
agents: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function AgentBadge({ agents }: AgentBadgeProps) {
|
|
38
|
+
if (!agents || agents.length === 0) return null
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Text color={colors.textDim}>
|
|
42
|
+
<Text color={colors.success}>{symbols.check}</Text> {agents.join(', ')}
|
|
43
|
+
</Text>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
3
|
+
import { act, render } from '@testing-library/react'
|
|
4
|
+
import * as fc from 'fast-check'
|
|
5
|
+
|
|
6
|
+
import { AnimatedTransition } from '../AnimatedTransition'
|
|
7
|
+
|
|
8
|
+
jest.mock('ink', () => ({
|
|
9
|
+
Transform: jest.fn(({ transform }) => {
|
|
10
|
+
const output = transform ? transform('child content') : 'child content'
|
|
11
|
+
return <div data-testid="ink-transform">{output}</div>
|
|
12
|
+
}),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
describe('AnimatedTransition - Property-Based Tests', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
jest.useFakeTimers()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
jest.clearAllTimers()
|
|
22
|
+
jest.useRealTimers()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should eventually render empty or full content based on final visibility for ANY duration', () => {
|
|
26
|
+
fc.assert(
|
|
27
|
+
fc.property(fc.boolean(), fc.integer({ min: -100, max: 10000 }), (isVisible, duration) => {
|
|
28
|
+
const { unmount, container } = render(
|
|
29
|
+
<AnimatedTransition visible={isVisible} duration={duration}>
|
|
30
|
+
<span>child</span>
|
|
31
|
+
</AnimatedTransition>,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const waitTime = Math.max(duration, 0) + 100
|
|
35
|
+
|
|
36
|
+
act(() => {
|
|
37
|
+
jest.advanceTimersByTime(waitTime)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (isVisible) {
|
|
41
|
+
expect(container.textContent).toContain('child')
|
|
42
|
+
} else {
|
|
43
|
+
expect(container.innerHTML).toBe('')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
unmount()
|
|
47
|
+
}),
|
|
48
|
+
{ numRuns: 100 },
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import * as fc from 'fast-check'
|
|
4
|
+
|
|
5
|
+
import { CategoryHeader } from '../CategoryHeader'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../services/badge-format', () => ({
|
|
8
|
+
formatCategoryBadge: (installed: number, total: number) => (installed > 0 ? `(${installed}/${total})` : `(${total})`),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.mock('../../services/category-colors', () => ({
|
|
12
|
+
getColorForCategory: () => '#64748b',
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('../../theme', () => ({
|
|
16
|
+
colors: {
|
|
17
|
+
accent: '#06b6d4',
|
|
18
|
+
primaryLight: '#60a5fa',
|
|
19
|
+
text: '#f8fafc',
|
|
20
|
+
textMuted: '#64748b',
|
|
21
|
+
textDim: '#94a3b8',
|
|
22
|
+
success: '#22c55e',
|
|
23
|
+
},
|
|
24
|
+
symbols: {
|
|
25
|
+
bullet: '\u25B8',
|
|
26
|
+
dot: '\u00B7',
|
|
27
|
+
},
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
jest.mock('ink', () => ({
|
|
31
|
+
Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
32
|
+
const { flexDirection, width, marginTop, marginBottom, ...validProps } = props
|
|
33
|
+
void flexDirection
|
|
34
|
+
void width
|
|
35
|
+
void marginTop
|
|
36
|
+
void marginBottom
|
|
37
|
+
return (
|
|
38
|
+
<div data-testid="box" {...validProps}>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
44
|
+
const { bold, underline, ...validProps } = props
|
|
45
|
+
void bold
|
|
46
|
+
void underline
|
|
47
|
+
return (
|
|
48
|
+
<span data-testid="text" {...validProps}>
|
|
49
|
+
{children}
|
|
50
|
+
</span>
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
describe('CategoryHeader property tests', () => {
|
|
56
|
+
it('should display correct badge format for any valid skill counts', () => {
|
|
57
|
+
fc.assert(
|
|
58
|
+
fc.property(fc.nat({ max: 100 }), fc.nat({ max: 100 }), (installed, total) => {
|
|
59
|
+
const validInstalled = Math.min(installed, total)
|
|
60
|
+
const { container, unmount } = render(
|
|
61
|
+
<CategoryHeader name="Test" totalCount={total} installedCount={validInstalled} />,
|
|
62
|
+
)
|
|
63
|
+
const text = container.textContent ?? ''
|
|
64
|
+
|
|
65
|
+
if (validInstalled > 0) {
|
|
66
|
+
expect(text).toContain(`(${validInstalled}/${total})`)
|
|
67
|
+
} else {
|
|
68
|
+
expect(text).toContain(`(${total})`)
|
|
69
|
+
}
|
|
70
|
+
unmount()
|
|
71
|
+
}),
|
|
72
|
+
{ numRuns: 200 },
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should update badge immediately when installed count changes', () => {
|
|
77
|
+
fc.assert(
|
|
78
|
+
fc.property(fc.nat({ max: 50 }), fc.nat({ max: 50 }), (countA, countB) => {
|
|
79
|
+
const total = 50
|
|
80
|
+
const installedA = Math.min(countA, total)
|
|
81
|
+
const installedB = Math.min(countB, total)
|
|
82
|
+
|
|
83
|
+
const { container, rerender, unmount } = render(
|
|
84
|
+
<CategoryHeader name="Test" totalCount={total} installedCount={installedA} />,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const textBefore = container.textContent ?? ''
|
|
88
|
+
if (installedA > 0) {
|
|
89
|
+
expect(textBefore).toContain(`(${installedA}/${total})`)
|
|
90
|
+
} else {
|
|
91
|
+
expect(textBefore).toContain(`(${total})`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
rerender(<CategoryHeader name="Test" totalCount={total} installedCount={installedB} />)
|
|
95
|
+
|
|
96
|
+
const textAfter = container.textContent ?? ''
|
|
97
|
+
if (installedB > 0) {
|
|
98
|
+
expect(textAfter).toContain(`(${installedB}/${total})`)
|
|
99
|
+
} else {
|
|
100
|
+
expect(textAfter).toContain(`(${total})`)
|
|
101
|
+
}
|
|
102
|
+
unmount()
|
|
103
|
+
}),
|
|
104
|
+
{ numRuns: 200 },
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
|
|
4
|
+
import { CategoryHeader } from '../CategoryHeader'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../services/badge-format', () => ({
|
|
7
|
+
formatCategoryBadge: (installed: number, total: number) => (installed > 0 ? `(${installed}/${total})` : `(${total})`),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.mock('../../services/category-colors', () => ({
|
|
11
|
+
getColorForCategory: (id: string) => (id === 'web' ? '#3b82f6' : '#64748b'),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../../theme', () => ({
|
|
15
|
+
colors: {
|
|
16
|
+
accent: '#06b6d4',
|
|
17
|
+
primaryLight: '#60a5fa',
|
|
18
|
+
text: '#f8fafc',
|
|
19
|
+
textMuted: '#64748b',
|
|
20
|
+
textDim: '#94a3b8',
|
|
21
|
+
success: '#22c55e',
|
|
22
|
+
},
|
|
23
|
+
symbols: {
|
|
24
|
+
bullet: '\u25B8',
|
|
25
|
+
dot: '\u00B7',
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
jest.mock('ink', () => ({
|
|
30
|
+
Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
31
|
+
const { flexDirection, width, marginTop, marginBottom, ...validProps } = props
|
|
32
|
+
void flexDirection
|
|
33
|
+
void width
|
|
34
|
+
void marginTop
|
|
35
|
+
void marginBottom
|
|
36
|
+
return (
|
|
37
|
+
<div data-testid="box" {...validProps}>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
43
|
+
const { bold, underline, ...validProps } = props
|
|
44
|
+
void bold
|
|
45
|
+
void underline
|
|
46
|
+
return (
|
|
47
|
+
<span data-testid="text" {...validProps}>
|
|
48
|
+
{children}
|
|
49
|
+
</span>
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
describe('CategoryHeader', () => {
|
|
55
|
+
it('renders category name', () => {
|
|
56
|
+
render(<CategoryHeader name="Web" totalCount={5} />)
|
|
57
|
+
expect(screen.getByText('Web')).toBeTruthy()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('displays badge with total only when no installed skills', () => {
|
|
61
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={8} installedCount={0} />)
|
|
62
|
+
expect(container.textContent).toContain('(8)')
|
|
63
|
+
expect(container.textContent).not.toContain('/')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('displays badge with installed/total when skills are installed', () => {
|
|
67
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={8} installedCount={3} />)
|
|
68
|
+
expect(container.textContent).toContain('(3/8)')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows expand hint when collapsed and focused', () => {
|
|
72
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} isFocused={true} />)
|
|
73
|
+
expect(container.textContent).toContain('press space to expand')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('hides expand hint when expanded', () => {
|
|
77
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={true} isFocused={true} />)
|
|
78
|
+
expect(container.textContent).not.toContain('press space to expand')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('hides expand hint when not focused', () => {
|
|
82
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} isFocused={false} />)
|
|
83
|
+
expect(container.textContent).not.toContain('press space to expand')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('shows collapsed chevron when not expanded', () => {
|
|
87
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} />)
|
|
88
|
+
expect(container.textContent).toContain('\u25B8')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('shows expanded chevron when expanded', () => {
|
|
92
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={true} />)
|
|
93
|
+
expect(container.textContent).toContain('\u25BE')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('uses category-specific color via categoryId', () => {
|
|
97
|
+
const { container } = render(<CategoryHeader name="Web" categoryId="web" totalCount={5} />)
|
|
98
|
+
expect(container.textContent).toContain('Web')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('highlights badge in success color when installed count > 0', () => {
|
|
102
|
+
const { container } = render(<CategoryHeader name="Web" totalCount={10} installedCount={5} />)
|
|
103
|
+
expect(container.textContent).toContain('(5/10)')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
3
|
+
import { act, render } from '@testing-library/react'
|
|
4
|
+
import * as fc from 'fast-check'
|
|
5
|
+
|
|
6
|
+
import { KeyboardShortcutsOverlay } from '../KeyboardShortcutsOverlay'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../theme', () => ({
|
|
9
|
+
colors: {
|
|
10
|
+
primary: '#3b82f6',
|
|
11
|
+
primaryLight: '#60a5fa',
|
|
12
|
+
accent: '#06b6d4',
|
|
13
|
+
text: '#f8fafc',
|
|
14
|
+
textDim: '#94a3b8',
|
|
15
|
+
textMuted: '#64748b',
|
|
16
|
+
border: '#334155',
|
|
17
|
+
bg: '#0f172a',
|
|
18
|
+
bgLight: '#1e293b',
|
|
19
|
+
success: '#22c55e',
|
|
20
|
+
error: '#ef4444',
|
|
21
|
+
},
|
|
22
|
+
symbols: {
|
|
23
|
+
sparkle: '✦',
|
|
24
|
+
diamond: '◆',
|
|
25
|
+
arrow: '›',
|
|
26
|
+
dot: '·',
|
|
27
|
+
bullet: '▸',
|
|
28
|
+
cross: '✗',
|
|
29
|
+
info: 'ℹ',
|
|
30
|
+
arrowDown: '↓',
|
|
31
|
+
arrowUp: '↑',
|
|
32
|
+
bar: '│',
|
|
33
|
+
},
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
jest.mock('../AnimatedTransition', () => ({
|
|
37
|
+
AnimatedTransition: ({ children, visible }: { children: React.ReactNode; visible: boolean }) =>
|
|
38
|
+
visible ? <div data-testid="overlay">{children}</div> : null,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const mockUseInput = jest.fn()
|
|
42
|
+
|
|
43
|
+
jest.mock('ink', () => {
|
|
44
|
+
return {
|
|
45
|
+
useInput: (handler: (input: string, key: Record<string, boolean>) => void, options: Record<string, unknown>) =>
|
|
46
|
+
mockUseInput(handler, options),
|
|
47
|
+
Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
48
|
+
const {
|
|
49
|
+
flexDirection,
|
|
50
|
+
justifyContent,
|
|
51
|
+
borderStyle,
|
|
52
|
+
borderColor,
|
|
53
|
+
paddingLeft,
|
|
54
|
+
paddingRight,
|
|
55
|
+
marginTop,
|
|
56
|
+
marginBottom,
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
gap,
|
|
60
|
+
...validProps
|
|
61
|
+
} = props
|
|
62
|
+
void flexDirection
|
|
63
|
+
void justifyContent
|
|
64
|
+
void borderStyle
|
|
65
|
+
void borderColor
|
|
66
|
+
void paddingLeft
|
|
67
|
+
void paddingRight
|
|
68
|
+
void marginTop
|
|
69
|
+
void marginBottom
|
|
70
|
+
void width
|
|
71
|
+
void height
|
|
72
|
+
void gap
|
|
73
|
+
return (
|
|
74
|
+
<div data-testid="box" {...validProps}>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
80
|
+
const { bold, underline, ...validProps } = props
|
|
81
|
+
void bold
|
|
82
|
+
void underline
|
|
83
|
+
return (
|
|
84
|
+
<span data-testid="text" {...validProps}>
|
|
85
|
+
{children}
|
|
86
|
+
</span>
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const defaultShortcuts = [
|
|
93
|
+
{ key: 'space', description: 'Toggle selection' },
|
|
94
|
+
{ key: 'enter', description: 'Confirm' },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
describe('KeyboardShortcutsOverlay - Property-Based Tests', () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
jest.useFakeTimers()
|
|
100
|
+
mockUseInput.mockClear()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
jest.clearAllTimers()
|
|
105
|
+
jest.useRealTimers()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should dismiss on ANY random key press when visible', () => {
|
|
109
|
+
fc.assert(
|
|
110
|
+
fc.property(
|
|
111
|
+
// Generate random key inputs
|
|
112
|
+
fc.record({
|
|
113
|
+
input: fc.string(),
|
|
114
|
+
key: fc.record({
|
|
115
|
+
return: fc.boolean(),
|
|
116
|
+
escape: fc.boolean(),
|
|
117
|
+
ctrl: fc.boolean(),
|
|
118
|
+
meta: fc.boolean(),
|
|
119
|
+
shift: fc.boolean(),
|
|
120
|
+
tab: fc.boolean(),
|
|
121
|
+
backspace: fc.boolean(),
|
|
122
|
+
delete: fc.boolean(),
|
|
123
|
+
upArrow: fc.boolean(),
|
|
124
|
+
downArrow: fc.boolean(),
|
|
125
|
+
leftArrow: fc.boolean(),
|
|
126
|
+
rightArrow: fc.boolean(),
|
|
127
|
+
pageDown: fc.boolean(),
|
|
128
|
+
pageUp: fc.boolean(),
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
(keyEvent) => {
|
|
132
|
+
const onDismiss = jest.fn()
|
|
133
|
+
mockUseInput.mockClear()
|
|
134
|
+
|
|
135
|
+
const { unmount } = render(
|
|
136
|
+
<KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (mockUseInput.mock.calls.length > 0) {
|
|
140
|
+
const handler = mockUseInput.mock.calls[0][0] as (input: string, key: unknown) => void
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
handler(keyEvent.input, keyEvent.key)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
unmount()
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
{ numRuns: 100 },
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import { act, render, screen } from '@testing-library/react'
|
|
3
|
+
|
|
4
|
+
import { KeyboardShortcutsOverlay } from '../KeyboardShortcutsOverlay'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../theme', () => ({
|
|
7
|
+
colors: {
|
|
8
|
+
primary: '#3b82f6',
|
|
9
|
+
primaryLight: '#60a5fa',
|
|
10
|
+
accent: '#06b6d4',
|
|
11
|
+
text: '#f8fafc',
|
|
12
|
+
textDim: '#94a3b8',
|
|
13
|
+
textMuted: '#64748b',
|
|
14
|
+
border: '#334155',
|
|
15
|
+
bg: '#0f172a',
|
|
16
|
+
bgLight: '#1e293b',
|
|
17
|
+
success: '#22c55e',
|
|
18
|
+
error: '#ef4444',
|
|
19
|
+
},
|
|
20
|
+
symbols: {
|
|
21
|
+
sparkle: '✦',
|
|
22
|
+
diamond: '◆',
|
|
23
|
+
arrow: '›',
|
|
24
|
+
dot: '·',
|
|
25
|
+
bullet: '▸',
|
|
26
|
+
cross: '✗',
|
|
27
|
+
info: 'ℹ',
|
|
28
|
+
arrowDown: '↓',
|
|
29
|
+
arrowUp: '↑',
|
|
30
|
+
bar: '│',
|
|
31
|
+
},
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
jest.mock('../AnimatedTransition', () => ({
|
|
35
|
+
AnimatedTransition: ({ children, visible }: { children: React.ReactNode; visible: boolean }) =>
|
|
36
|
+
visible ? <div data-testid="overlay">{children}</div> : null,
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
const mockUseInput = jest.fn()
|
|
40
|
+
|
|
41
|
+
jest.mock('ink', () => {
|
|
42
|
+
return {
|
|
43
|
+
useInput: (handler: (input: string, key: Record<string, boolean>) => void, options: Record<string, unknown>) =>
|
|
44
|
+
mockUseInput(handler, options),
|
|
45
|
+
Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
46
|
+
const {
|
|
47
|
+
flexDirection,
|
|
48
|
+
justifyContent,
|
|
49
|
+
borderStyle,
|
|
50
|
+
borderColor,
|
|
51
|
+
paddingLeft,
|
|
52
|
+
paddingRight,
|
|
53
|
+
marginTop,
|
|
54
|
+
marginBottom,
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
gap,
|
|
58
|
+
...validProps
|
|
59
|
+
} = props
|
|
60
|
+
void flexDirection
|
|
61
|
+
void justifyContent
|
|
62
|
+
void borderStyle
|
|
63
|
+
void borderColor
|
|
64
|
+
void paddingLeft
|
|
65
|
+
void paddingRight
|
|
66
|
+
void marginTop
|
|
67
|
+
void marginBottom
|
|
68
|
+
void width
|
|
69
|
+
void height
|
|
70
|
+
void gap
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div data-testid="box" {...validProps}>
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
|
|
79
|
+
const { bold, underline, ...validProps } = props
|
|
80
|
+
void bold
|
|
81
|
+
void underline
|
|
82
|
+
return (
|
|
83
|
+
<span data-testid="text" {...validProps}>
|
|
84
|
+
{children}
|
|
85
|
+
</span>
|
|
86
|
+
)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const defaultShortcuts = [
|
|
92
|
+
{ key: 'space', description: 'Toggle selection' },
|
|
93
|
+
{ key: 'enter', description: 'Confirm' },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
describe('KeyboardShortcutsOverlay', () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.useFakeTimers()
|
|
99
|
+
mockUseInput.mockClear()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
jest.clearAllTimers()
|
|
104
|
+
jest.useRealTimers()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('renders correctly when visible', () => {
|
|
108
|
+
render(<KeyboardShortcutsOverlay visible={true} onDismiss={() => {}} shortcuts={defaultShortcuts} />)
|
|
109
|
+
expect(screen.getByTestId('overlay')).toBeTruthy()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('auto-dismisses after 8 seconds', () => {
|
|
113
|
+
const onDismiss = jest.fn()
|
|
114
|
+
render(<KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />)
|
|
115
|
+
|
|
116
|
+
act(() => {
|
|
117
|
+
jest.advanceTimersByTime(8000)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('dismisses on any key press', () => {
|
|
124
|
+
const onDismiss = jest.fn()
|
|
125
|
+
render(<KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />)
|
|
126
|
+
|
|
127
|
+
expect(mockUseInput).toHaveBeenCalled()
|
|
128
|
+
const handler = mockUseInput.mock.calls[0][0] as (input: string, key: Record<string, boolean>) => void
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
handler('a', { return: false })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
})
|