code-file-icon.client.spec.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. // @vitest-environment jsdom
  2. import { readFileSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { cleanup, render } from '@testing-library/react'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. classifyFileType, FileTypeIcon, type CodeFileType, type FileTypeProjectContext,
  8. } from '@deepseek-ai/dsh-client-ui-primitives'
  9. import { CODE_FILE_ARTWORK, CODE_FILE_ICON_ID_TOKEN } from '../src/code-file-icon-artwork.ts'
  10. import { CODE_FILE_TYPES } from '../src/code-file-types.ts'
  11. afterEach(cleanup)
  12. const flutter: FileTypeProjectContext = {
  13. files: { 'config/pubspec.yaml': 'name: demo\ndependencies:\n flutter: sdk' },
  14. }
  15. function normalizeCodeIconIds(html: string): string {
  16. return html.replace(/dsh-code-icon-[A-Za-z0-9_-]+/gu, 'dsh-code-icon-instance')
  17. }
  18. describe('code-file classification', () => {
  19. it.each([
  20. ['app.component.ts', 'angular'],
  21. ['file.c', 'c'],
  22. ['file.clj', 'clojure'],
  23. ['CMakeLists.txt', 'cmake'],
  24. ['file.cc', 'cpp'],
  25. ['file.cs', 'csharp'],
  26. ['file.css', 'css'],
  27. ['file.dart', 'dart'],
  28. ['Dockerfile', 'docker'],
  29. ['file.ex', 'elixir'],
  30. ['.env', 'env'],
  31. ['file.erl', 'erlang'],
  32. ['.gitignore', 'git'],
  33. ['file.go', 'go'],
  34. ['file.graphql', 'graphql'],
  35. ['file.hs', 'haskell'],
  36. ['file.ini', 'ini'],
  37. ['file.java', 'java'],
  38. ['file.js', 'javascript'],
  39. ['file.json', 'json'],
  40. ['file.kt', 'kotlin'],
  41. ['file.lua', 'lua'],
  42. ['Makefile', 'makefile'],
  43. ['package.json', 'node'],
  44. ['file.m', 'objective-c'],
  45. ['file.pl', 'perl'],
  46. ['file.php', 'php'],
  47. ['file.ps1', 'powershell'],
  48. ['file.proto', 'protobuf'],
  49. ['file.py', 'python'],
  50. ['file.r', 'r'],
  51. ['file.jsx', 'react'],
  52. ['Gemfile', 'ruby'],
  53. ['file.rs', 'rust'],
  54. ['file.scala', 'scala'],
  55. ['.bashrc', 'shell'],
  56. ['file.sol', 'solidity'],
  57. ['file.sql', 'sql'],
  58. ['file.svelte', 'svelte'],
  59. ['file.swift', 'swift'],
  60. ['file.toml', 'toml'],
  61. ['file.ts', 'typescript'],
  62. ['file.vue', 'vue'],
  63. ['file.wasm', 'wasm'],
  64. ['file.xml', 'xml'],
  65. ['file.yaml', 'yaml'],
  66. ['file.zig', 'zig'],
  67. ] as [string, CodeFileType][])('%s → %s', (path, type) => {
  68. expect(classifyFileType(path)).toBe(type)
  69. })
  70. it('applies filename priority before generic extensions', () => {
  71. expect(classifyFileType('package.json')).toBe('node')
  72. expect(classifyFileType('docker-compose.yaml')).toBe('docker')
  73. expect(classifyFileType('feature.component.ts')).toBe('angular')
  74. expect(classifyFileType('Dockerfile.local')).toBe('docker')
  75. expect(classifyFileType('.env.production')).toBe('env')
  76. })
  77. it('selects Flutter only when the supplied project snapshot identifies it', () => {
  78. expect(classifyFileType('lib/main.dart')).toBe('dart')
  79. expect(classifyFileType('lib/main.dart', { files: { 'pubspec.yaml': 'name: demo' } })).toBe('dart')
  80. expect(classifyFileType('lib/main.dart', flutter)).toBe('flutter')
  81. const view = render(<FileTypeIcon path="lib/main.dart" context={flutter} />)
  82. const explicit = render(<FileTypeIcon kind="flutter" />)
  83. expect(normalizeCodeIconIds(view.container.innerHTML))
  84. .toBe(normalizeCodeIconIds(explicit.container.innerHTML))
  85. })
  86. it('keeps Markdown and SVG on the traditional file artwork', () => {
  87. expect(classifyFileType('README.md')).toBe('markdown')
  88. expect(classifyFileType('logo.svg')).toBe('image')
  89. })
  90. })
  91. describe('full-color code-file artwork', () => {
  92. it('embeds exactly the established 48 code categories', () => {
  93. expect(Object.keys(CODE_FILE_ARTWORK)).toEqual([...CODE_FILE_TYPES])
  94. expect(CODE_FILE_ARTWORK).not.toHaveProperty('markdown')
  95. for (const [type, artwork] of Object.entries(CODE_FILE_ARTWORK)) {
  96. expect(artwork).not.toMatch(/<(?:script|style|foreignObject)\b|\son[a-z]+\s*=/iu)
  97. expect(artwork).not.toMatch(/(?:href|xlink:href)="(?!#)/iu)
  98. const ids = [...artwork.matchAll(/\bid="([^"]+)"/gu)].map(match => match[1]!)
  99. expect(new Set(ids).size, `${type} has duplicate SVG ids`).toBe(ids.length)
  100. for (const id of ids) expect(id).toMatch(new RegExp(`^${CODE_FILE_ICON_ID_TOKEN}`))
  101. const references = [
  102. ...[...artwork.matchAll(/url\(#([^)]+)\)/gu)].map(match => match[1]!),
  103. ...[...artwork.matchAll(/(?:href|xlink:href)="#([^"]+)"/gu)].map(match => match[1]!),
  104. ]
  105. for (const reference of references) {
  106. expect(reference).toMatch(new RegExp(`^${CODE_FILE_ICON_ID_TOKEN}`))
  107. expect(ids).toContain(reference)
  108. }
  109. }
  110. })
  111. it('records the design exports that supply the closed artwork set', () => {
  112. const path = resolve('packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json')
  113. const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
  114. owner: string
  115. sources: Array<{ kind: string; sha256: string; icons: string[] }>
  116. excluded: string[]
  117. }
  118. expect(manifest.owner).toBe('DeepSeek Harness product design')
  119. expect(manifest.sources.every(source => source.kind === 'internal-design-export')).toBe(true)
  120. expect(manifest.sources.every(source => /^[0-9a-f]{64}$/u.test(source.sha256))).toBe(true)
  121. expect(manifest.sources.flatMap(source => source.icons).sort()).toEqual([...CODE_FILE_TYPES].sort())
  122. expect(manifest.excluded).toEqual(['markdown'])
  123. })
  124. it.each(CODE_FILE_TYPES)('%s renders its supplied square SVG', (type) => {
  125. const { container } = render(<FileTypeIcon kind={type} />)
  126. const svg = container.querySelector('svg')!
  127. expect(svg.getAttribute('viewBox')).toBe('0 0 20 20')
  128. expect(svg.getAttribute('width')).toBe('28')
  129. expect(svg.getAttribute('aria-hidden')).toBe('true')
  130. expect(container.innerHTML).toMatch(/#[0-9a-fA-F]{3,8}/)
  131. expect(container.innerHTML).not.toContain('currentColor')
  132. })
  133. it('keeps every code category visually distinct', () => {
  134. const artwork = CODE_FILE_TYPES.map((type) => {
  135. const { container } = render(<FileTypeIcon kind={type} />)
  136. return container.querySelector('svg')!.innerHTML
  137. })
  138. expect(new Set(artwork).size).toBe(CODE_FILE_TYPES.length)
  139. })
  140. it('uses instance-safe gradient ids and forwards sizing classes', () => {
  141. const { container } = render(<><FileTypeIcon kind="elixir" /><FileTypeIcon kind="elixir" /></>)
  142. const ids = [...container.querySelectorAll('[id]')].map(element => element.id)
  143. expect(ids.length).toBeGreaterThan(2)
  144. expect(new Set(ids).size).toBe(ids.length)
  145. expect(container.innerHTML).not.toContain(CODE_FILE_ICON_ID_TOKEN)
  146. const references = [...container.querySelectorAll('*')].flatMap(element =>
  147. [...element.attributes].flatMap(attribute =>
  148. [...attribute.value.matchAll(/url\(#([^)]+)\)/gu)].map(match => match[1])),
  149. )
  150. expect(references.length).toBeGreaterThan(0)
  151. for (const reference of references) expect(ids).toContain(reference)
  152. const sized = render(<FileTypeIcon path="file.ts" size={16} className="x" />)
  153. const svg = sized.container.querySelector('svg')!
  154. expect(svg.getAttribute('width')).toBe('16')
  155. expect(svg.getAttribute('height')).toBe('16')
  156. expect(svg.classList.contains('x')).toBe(true)
  157. })
  158. })