|
1 | 1 | import { readFileSync } from 'node:fs'; |
2 | | -import { fileURLToPath } from 'node:url'; |
| 2 | +import { join } from 'node:path'; |
3 | 3 | import { flushPromises, mount } from '@vue/test-utils'; |
4 | 4 | import { createI18n } from 'vue-i18n'; |
5 | 5 | import { afterEach, describe, expect, it, vi } from 'vitest'; |
6 | 6 | import Composer from '../src/components/Composer.vue'; |
7 | 7 | import type { AppModel } from '../src/api/types'; |
8 | 8 |
|
9 | | -const sourcePath = (path: string) => fileURLToPath(new URL(path, import.meta.url)); |
| 9 | +const sourcePath = (path: string) => join(import.meta.dirname, path); |
10 | 10 | const composerSource = readFileSync(sourcePath('../src/components/Composer.vue'), 'utf8'); |
| 11 | +const conversationPaneSource = readFileSync(sourcePath('../src/components/ConversationPane.vue'), 'utf8'); |
| 12 | +const styleSource = readFileSync(sourcePath('../src/style.css'), 'utf8'); |
11 | 13 |
|
12 | 14 | function mountComposer(props: Record<string, unknown> = {}) { |
13 | 15 | const i18n = createI18n({ |
@@ -82,6 +84,80 @@ afterEach(() => { |
82 | 84 | }); |
83 | 85 |
|
84 | 86 | describe('Composer styling', () => { |
| 87 | + it('uses the shared xl radius token for the composer card', () => { |
| 88 | + const composerCardRule = composerSource.match(/(?:^|\n)\.composer-card\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 89 | + expect(composerCardRule.trim()).not.toBe(''); |
| 90 | + expect(composerCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/); |
| 91 | + |
| 92 | + const modernCardRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.composer-card\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 93 | + expect(modernCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/); |
| 94 | + |
| 95 | + const pythinkerCardRule = styleSource.match(/html\[data-theme="pythinker"\] \.composer-card\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 96 | + expect(pythinkerCardRule).toMatch(/border-radius:\s*var\(--r-xl\);/); |
| 97 | + }); |
| 98 | + |
| 99 | + it('defines the xl radius token in the shared style tokens', () => { |
| 100 | + expect(styleSource).toMatch(/--r-xl:\s*24px;/); |
| 101 | + }); |
| 102 | + |
| 103 | + it('uses a translucent card surface with a backdrop blur', () => { |
| 104 | + const composerCardRule = composerSource.match(/(?:^|\n)\.composer-card\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 105 | + expect(composerCardRule).toMatch(/background:\s*color-mix\([^;]+transparent\);/); |
| 106 | + expect(composerCardRule).toMatch(/backdrop-filter:\s*blur\([^;]+\);/); |
| 107 | + }); |
| 108 | + |
| 109 | + it('strengthens the card border on hover and focus within', () => { |
| 110 | + const interactionRule = composerSource.match(/(?:^|\n)\.composer-card:hover,\s*\.composer-card:focus-within\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 111 | + expect(interactionRule.trim()).not.toBe(''); |
| 112 | + expect(interactionRule).toMatch(/border-color:\s*[^;]+;/); |
| 113 | + }); |
| 114 | + |
| 115 | + it('caps the input at 384px and scrolls its content', () => { |
| 116 | + const inputRule = composerSource.match(/(?:^|\n)\.ph\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 117 | + expect(inputRule).toMatch(/max-height:\s*384px;/); |
| 118 | + expect(inputRule).toMatch(/overflow-y:\s*auto;/); |
| 119 | + expect(inputRule).toMatch(/field-sizing:\s*content;/); |
| 120 | + }); |
| 121 | + |
| 122 | + it('uses circular attachment controls and a divider after the plus button', () => { |
| 123 | + const attachRule = composerSource.match(/(?:^|\n)\.attach-btn\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 124 | + expect(attachRule).toMatch(/width:\s*30px;/); |
| 125 | + expect(attachRule).toMatch(/height:\s*30px;/); |
| 126 | + expect(attachRule).toMatch(/border-radius:\s*50%;/); |
| 127 | + |
| 128 | + const themedAttachRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.attach-btn\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 129 | + expect(themedAttachRule).toMatch(/width:\s*30px;/); |
| 130 | + expect(themedAttachRule).toMatch(/height:\s*30px;/); |
| 131 | + expect(themedAttachRule).toMatch(/border-radius:\s*50%;/); |
| 132 | + |
| 133 | + const dividerRule = composerSource.match(/(?:^|\n)\.toolbar-divider\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 134 | + expect(dividerRule).toMatch(/width:\s*1px;/); |
| 135 | + expect(dividerRule).toMatch(/height:\s*16px;/); |
| 136 | + expect(composerSource).toMatch(/class="attach-btn"[\s\S]*class="toolbar-divider"/); |
| 137 | + }); |
| 138 | + |
| 139 | + it('pads the send button around a 20px glyph and keeps the accent fill', () => { |
| 140 | + const sendRule = composerSource.match(/(?:^|\n)\.send\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 141 | + expect(sendRule).toMatch(/padding:\s*5px;/); |
| 142 | + // The send button carries the theme accent, not a monochrome fill: --blue is |
| 143 | + // the Pythinker brand colour and each theme redefines it. |
| 144 | + expect(sendRule).toMatch(/background:\s*var\(--blue\);/); |
| 145 | + expect(sendRule).toMatch(/color:\s*var\(--bg\);/); |
| 146 | + |
| 147 | + const sendIconRule = composerSource.match(/(?:^|\n)\.send svg\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 148 | + expect(sendIconRule).toMatch(/width:\s*20px;/); |
| 149 | + expect(sendIconRule).toMatch(/height:\s*20px;/); |
| 150 | + |
| 151 | + const themedSendRule = styleSource.match(/:is\(html\[data-theme="modern"\], html\[data-theme="pythinker"\]\) \.send\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 152 | + expect(themedSendRule).toMatch(/padding:\s*5px;/); |
| 153 | + expect(themedSendRule).not.toMatch(/background:\s*var\(--ink\);/); |
| 154 | + }); |
| 155 | + |
| 156 | + it('widens the shared reading column to 928px', () => { |
| 157 | + const conRule = conversationPaneSource.match(/(?:^|\n)\.con\s*\{([^}]*)\}/)?.[1] ?? ''; |
| 158 | + expect(conRule).toMatch(/--read-max:\s*928px;/); |
| 159 | + }); |
| 160 | + |
85 | 161 | it('places the composer border flush against the message list', () => { |
86 | 162 | const composerRule = composerSource.match(/(?:^|\n)\.composer\s*\{([^}]*)\}/)?.[1] ?? ''; |
87 | 163 | expect(composerRule.trim()).not.toBe(''); |
|
0 commit comments