mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 增加日报模板
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
|
||||
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
||||
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
|
||||
const noImageInsights = {
|
||||
total: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
items: [],
|
||||
failures: []
|
||||
}
|
||||
|
||||
const currentModel = {
|
||||
providerId: 'provider-1',
|
||||
providerName: '默认服务',
|
||||
model: 'model-1',
|
||||
modelName: '默认模型',
|
||||
configured: true,
|
||||
status: 'connected' as const
|
||||
}
|
||||
|
||||
const groupContact: Contact = {
|
||||
md5: 'group-md5',
|
||||
@@ -18,6 +38,7 @@ describe('daily report controls', () => {
|
||||
value: {
|
||||
getAppLogPath: vi.fn(async () => ''),
|
||||
revealAppLog: vi.fn(async () => undefined),
|
||||
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
||||
getGroupSnapshot: vi.fn(async () => ({
|
||||
members: [
|
||||
{
|
||||
@@ -50,7 +71,13 @@ describe('daily report controls', () => {
|
||||
error=""
|
||||
voiceTranscriptionProgress={progress}
|
||||
voiceTranscriptionEnabled
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -63,13 +90,270 @@ describe('daily report controls', () => {
|
||||
error=""
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2/4')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows image insight results and pauses for confirmation when some images fail', () => {
|
||||
const onContinue = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<ReportTaskStatusPanel
|
||||
phase="awaitingImageDecision"
|
||||
error=""
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={{
|
||||
stage: 'summarizingInput',
|
||||
label: '等待确认是否继续文字总结',
|
||||
completed: 2,
|
||||
total: 3
|
||||
}}
|
||||
imageInsightSummary={{
|
||||
total: 3,
|
||||
succeeded: 2,
|
||||
failed: 1,
|
||||
items: [
|
||||
{
|
||||
messageId: 'image-1',
|
||||
sender: '成员一',
|
||||
time: '10:20',
|
||||
description: '一张表格型网页截图。',
|
||||
ocrText: '列 A 列 B',
|
||||
tags: ['表格', '网页']
|
||||
}
|
||||
],
|
||||
failures: [
|
||||
{
|
||||
messageId: 'image-2',
|
||||
sender: '成员二',
|
||||
time: '10:21',
|
||||
error: 'fetch failed'
|
||||
}
|
||||
]
|
||||
}}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={onContinue}
|
||||
onCancelAfterImageFailures={onCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('等待确认')).toBeVisible()
|
||||
expect(screen.getByText('有 1 张图片识别失败')).toBeVisible()
|
||||
expect(screen.getByText('一张表格型网页截图。')).toBeVisible()
|
||||
expect(screen.getByText('OCR:列 A 列 B')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续文字总结' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止生成' }))
|
||||
expect(onContinue).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows switching models and retrying only the model step', async () => {
|
||||
const onRetry = vi.fn()
|
||||
window.api.listAIProviders = vi.fn(async () => ({
|
||||
success: true,
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: '备用服务',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: 'https://example.test',
|
||||
auth: { type: 'bearer' },
|
||||
models: [
|
||||
{
|
||||
id: 'model-2',
|
||||
name: '备用模型',
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: 'model-2',
|
||||
advanced: { timeoutMs: 120000, extraHeaders: {} },
|
||||
hasApiKey: true,
|
||||
isDefault: false,
|
||||
status: 'connected'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
render(
|
||||
<ReportTaskStatusPanel
|
||||
phase="error"
|
||||
error="fetch failed"
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep
|
||||
currentModel={currentModel}
|
||||
onRetry={onRetry}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
|
||||
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
|
||||
fireEvent.click(retryButton)
|
||||
expect(onRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
|
||||
)
|
||||
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
return undefined
|
||||
}
|
||||
disconnect(): void {
|
||||
return undefined
|
||||
}
|
||||
unobserve(): void {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-1',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture'
|
||||
}
|
||||
render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||
/>
|
||||
)
|
||||
const image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1440 })
|
||||
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 4000 })
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 760
|
||||
})
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 600
|
||||
})
|
||||
fireEvent.load(image)
|
||||
expect(image.style.width).toBe('200px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '缩小' }))
|
||||
expect(image.style.width).toBe('160px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大' }))
|
||||
expect(image.style.width).toBe('200px')
|
||||
expect(screen.getByRole('button', { name: '完整显示' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '原始大小' }))
|
||||
expect(image.style.width).toBe('1440px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '完整显示' }))
|
||||
expect(image.style.width).toBe('200px')
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-switch',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
templateId: 'mobile-feed',
|
||||
reportSnapshot: {} as GeneratedReportRecord['reportSnapshot'],
|
||||
reportMetadata: {} as GeneratedReportRecord['reportMetadata']
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
|
||||
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
|
||||
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
|
||||
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
|
||||
|
||||
rerender(
|
||||
<ReportViewer
|
||||
report={{
|
||||
...report,
|
||||
reportSnapshot: undefined,
|
||||
reportMetadata: undefined,
|
||||
htmlPath: '/tmp/legacy-report.html'
|
||||
}}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
|
||||
'title',
|
||||
'使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
|
||||
)
|
||||
|
||||
rerender(
|
||||
<ReportViewer
|
||||
report={{
|
||||
...report,
|
||||
reportSnapshot: undefined,
|
||||
reportMetadata: undefined,
|
||||
reportRenderSnapshot: undefined,
|
||||
htmlPath: undefined
|
||||
}}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
|
||||
'title',
|
||||
'当前报告缺少可复用数据和 HTML,无法切换模板'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
|
||||
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
|
||||
|
||||
@@ -78,4 +362,28 @@ describe('daily report controls', () => {
|
||||
expect(screen.getByText('通讯录备注一')).toBeVisible()
|
||||
expect(screen.getByText('wxid-one')).toBeVisible()
|
||||
})
|
||||
|
||||
it('offers the classic default plus three mobile and two desktop report templates', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(6)
|
||||
expect(screen.getByText('默认模板', { selector: '.report-template-group-title' })).toBeVisible()
|
||||
expect(screen.getByText('手机端 · 375–414 px')).toBeVisible()
|
||||
expect(screen.getByText('电脑端 · 1280–1920 px')).toBeVisible()
|
||||
expect(screen.getByText('经典日报')).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: /经典日报/ })).toBeChecked()
|
||||
expect(screen.getByText('微信信息流')).toBeVisible()
|
||||
expect(screen.getByText('AI Magazine')).toBeVisible()
|
||||
expect(screen.getByText('AI Command Center')).toBeVisible()
|
||||
expect(screen.getByText('三栏 AI 工作台')).toBeVisible()
|
||||
expect(screen.getByText('Editorial 科技日报')).toBeVisible()
|
||||
|
||||
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
|
||||
expect(previewButtons).toHaveLength(6)
|
||||
fireEvent.click(previewButtons[2])
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -479,11 +479,30 @@ handle('report:export', () => {
|
||||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||||
})
|
||||
handle('report:exportSnapshot', () => {
|
||||
const htmlPath = path.join(userData, 'fixture-report-snapshot.html')
|
||||
const pngPath = path.join(userData, 'fixture-report-snapshot.png')
|
||||
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏模板快照日报</h1>', 'utf8')
|
||||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||||
})
|
||||
handle('report:prepareTemplateSwitch', () => ({
|
||||
success: true,
|
||||
snapshot: {
|
||||
groupName: '固定脱敏群',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '固定脱敏群日报' }
|
||||
}
|
||||
}))
|
||||
handle('report:listGenerated', () => ({ success: true, reports: [] }))
|
||||
handle('report:saveGenerated', (request) => ({
|
||||
success: true,
|
||||
record: { id: 'fixture-report-record', ...request }
|
||||
}))
|
||||
handle('report:updateGeneratedTemplate', (request) => ({
|
||||
success: true,
|
||||
record: { id: request.reportId, templateId: request.templateId }
|
||||
}))
|
||||
handle('report:deleteGenerated', () => ({ success: true }))
|
||||
handle('report:reveal', () => ({ success: true }))
|
||||
handle('copy-image', () => ({ success: true }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
parseGroupDailyReport
|
||||
@@ -29,6 +29,16 @@ const media = {
|
||||
funBadges: []
|
||||
}
|
||||
|
||||
const previousWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
afterEach(() => {
|
||||
if (previousWindow === undefined) {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow })
|
||||
}
|
||||
})
|
||||
|
||||
describe('group report parsing', () => {
|
||||
it('keeps only distinct real participants in the hero avatar list', () => {
|
||||
expect(
|
||||
@@ -113,6 +123,109 @@ describe('group report parsing', () => {
|
||||
expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。')
|
||||
})
|
||||
|
||||
it('injects successful image insights into the model prompt and reports partial failures', async () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'image-1',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:00:00',
|
||||
content: '[图片]',
|
||||
name: '成员一',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'one.dat' }
|
||||
},
|
||||
{
|
||||
id: 'image-2',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:01:00',
|
||||
content: '[图片]',
|
||||
name: '成员二',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'b'.repeat(32), datName: 'two.dat' }
|
||||
}
|
||||
]
|
||||
const progress = vi.fn()
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: {
|
||||
imageListCandidates: vi.fn(async () => ({
|
||||
success: true,
|
||||
candidates: [
|
||||
{
|
||||
messageId: 'image-1',
|
||||
imageHash: 'a'.repeat(32),
|
||||
md5: 'a'.repeat(32),
|
||||
datName: 'one.dat',
|
||||
sessionId: 'group@chatroom',
|
||||
sender: '成员一',
|
||||
sentAt: new Date('2026-08-12 10:00:00').getTime(),
|
||||
heatScore: 10
|
||||
},
|
||||
{
|
||||
messageId: 'image-2',
|
||||
imageHash: 'b'.repeat(32),
|
||||
md5: 'b'.repeat(32),
|
||||
datName: 'two.dat',
|
||||
sessionId: 'group@chatroom',
|
||||
sender: '成员二',
|
||||
sentAt: new Date('2026-08-12 10:01:00').getTime(),
|
||||
heatScore: 9
|
||||
}
|
||||
]
|
||||
})),
|
||||
getImage: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: 'data:image/png;base64,fixture'
|
||||
})),
|
||||
imageAnalyze: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
insight: {
|
||||
id: 'insight-1',
|
||||
messageId: 'image-1',
|
||||
imageHash: 'a'.repeat(32),
|
||||
description: '一张表格型网页截图,包含多列数据。',
|
||||
ocrText: '项目 状态 负责人',
|
||||
tags: ['表格', '管理界面'],
|
||||
category: 'screenshot',
|
||||
importance: 'medium',
|
||||
provider: 'vision-provider',
|
||||
model: 'vision-model',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sender: '成员一',
|
||||
sentAt: new Date('2026-08-12 10:00:00').getTime(),
|
||||
sessionId: 'group@chatroom'
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ success: false, error: 'fetch failed' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = await buildGroupReportInput(messages, null, true, 'full', {
|
||||
onProgress: progress
|
||||
})
|
||||
|
||||
expect(input.prompt).toContain('AI 图片识别摘要:')
|
||||
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
|
||||
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
||||
expect(input.imageInsightSummary).toMatchObject({ total: 2, succeeded: 1, failed: 1 })
|
||||
expect(input.imageInsightSummary.failures[0]).toMatchObject({
|
||||
messageId: 'image-2',
|
||||
error: 'fetch failed'
|
||||
})
|
||||
expect(progress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stage: 'recognizingImages', completed: 2, total: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to topic keywords when the model omits top-level keywords', () => {
|
||||
const report = parseGroupDailyReport(
|
||||
JSON.stringify({
|
||||
@@ -133,4 +246,66 @@ describe('group report parsing', () => {
|
||||
|
||||
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
|
||||
})
|
||||
|
||||
it('does not render the legacy gallery even when old report data contains it', () => {
|
||||
const report = parseGroupDailyReport(
|
||||
JSON.stringify({
|
||||
topics: [{ title: '图片话题', summary: '围绕图片展开讨论。', keywords: ['图片'] }]
|
||||
}),
|
||||
[],
|
||||
'',
|
||||
[],
|
||||
metadata,
|
||||
{
|
||||
gallery: [
|
||||
{
|
||||
sender: '成员一',
|
||||
time: '10:00',
|
||||
imageUrl: 'data:image/png;base64,fixture',
|
||||
note: '旧相册数据'
|
||||
}
|
||||
],
|
||||
voiceHighlights: [],
|
||||
funBadges: []
|
||||
}
|
||||
)
|
||||
|
||||
expect(report.media.gallery).toEqual([])
|
||||
expect(report.sectionMeta?.gallery).toMatchObject({ enabled: false, displayedCount: 0 })
|
||||
})
|
||||
|
||||
it('allows a text report with zero AI images when no image reaches the hot threshold', async () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: {
|
||||
imageListCandidates: vi.fn(async () => ({ success: true, candidates: [] })),
|
||||
getImage: vi.fn(),
|
||||
imageAnalyze: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = await buildGroupReportInput(
|
||||
[
|
||||
{
|
||||
id: 'cold-image',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:00:00',
|
||||
content: '[图片]',
|
||||
name: '成员一',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'cold.dat' }
|
||||
}
|
||||
],
|
||||
null,
|
||||
true,
|
||||
'full'
|
||||
)
|
||||
|
||||
expect(input.imageInsightSummary).toMatchObject({ total: 0, succeeded: 0, failed: 0 })
|
||||
expect(input.media.gallery).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() }))
|
||||
|
||||
vi.mock('../../src/main/db/image-insights-store', () => ({
|
||||
imageInsightsStore: {
|
||||
getByHash,
|
||||
upsert: vi.fn(),
|
||||
listBySession: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { imageInsightService } from '../../src/main/services/image-insight-service'
|
||||
|
||||
const query = {
|
||||
sessionId: 'group@chatroom',
|
||||
startTime: 0,
|
||||
endTime: Date.now(),
|
||||
limit: 3
|
||||
}
|
||||
|
||||
const input = (id: string, responseCount: number, interactionCount: number): {
|
||||
messageId: string
|
||||
md5: string
|
||||
sessionId: string
|
||||
sender: string
|
||||
sentAt: number
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
} => ({
|
||||
messageId: id,
|
||||
md5: id.repeat(32).slice(0, 32),
|
||||
sessionId: 'group@chatroom',
|
||||
sender: id,
|
||||
sentAt: Date.now(),
|
||||
responseCount,
|
||||
interactionCount
|
||||
})
|
||||
|
||||
describe('ImageInsightService hot image selection', () => {
|
||||
beforeEach(() => {
|
||||
getByHash.mockReset()
|
||||
getByHash.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('returns fewer than three images when only two pass the hot threshold', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 3, 0),
|
||||
input('b', 1, 1),
|
||||
input('c', 1, 0),
|
||||
input('d', 0, 5),
|
||||
input('e', 0, 0)
|
||||
])
|
||||
|
||||
expect(result.map((item) => item.messageId)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps the three highest-scoring hot images when more are eligible', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 2, 0),
|
||||
input('b', 5, 0),
|
||||
input('c', 1, 1),
|
||||
input('d', 3, 1)
|
||||
])
|
||||
|
||||
expect(result.map((item) => item.messageId)).toEqual(['b', 'd', 'a'])
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns no candidates when no image has meaningful follow-up activity', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 1, 0),
|
||||
input('b', 0, 4),
|
||||
input('c', 0, 0)
|
||||
])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a cached insight attached to an eligible candidate', async () => {
|
||||
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' }
|
||||
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
|
||||
|
||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||
|
||||
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { GroupDailyReport, GroupReportMetadata } from '../../src/shared/group-report'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'tracememo-report-history-'))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root },
|
||||
nativeImage: {
|
||||
createFromPath: () => ({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: 430, height: 1200 })
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const reportSnapshot = {
|
||||
overview: '已生成的结构化日报',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: {
|
||||
topicHeat: [],
|
||||
activeTimeline: '',
|
||||
topSpeakers: [],
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: [],
|
||||
media: { gallery: [], visionGallery: [], voiceHighlights: [], funBadges: [] }
|
||||
} satisfies GroupDailyReport
|
||||
|
||||
const reportMetadata = {
|
||||
groupName: '测试群',
|
||||
reportDate: '2026-08-12',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
activeUsers: 3,
|
||||
timeSpan: '09:00–18:00',
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
recordNote: '',
|
||||
footerNote: '',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
} satisfies GroupReportMetadata
|
||||
|
||||
describe('generated report template history', () => {
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('replaces the current report assets while preserving its structured snapshot and id', async () => {
|
||||
const originalHtml = join(root, 'original.html')
|
||||
const switchedHtml = join(root, 'switched.html')
|
||||
writeFileSync(originalHtml, '<h1>Mobile 01</h1>')
|
||||
writeFileSync(switchedHtml, '<h1>Mobile 03</h1>')
|
||||
const { saveGeneratedReport, updateGeneratedReportTemplate } =
|
||||
await import('../../src/main/report-history-service')
|
||||
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
|
||||
htmlPath: originalHtml,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: 'mobile-feed'
|
||||
})
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.record).toBeDefined()
|
||||
|
||||
const updated = await updateGeneratedReportTemplate({
|
||||
reportId: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('mobile-03').toString('base64')}`,
|
||||
htmlPath: switchedHtml
|
||||
})
|
||||
|
||||
expect(updated.success).toBe(true)
|
||||
expect(updated.record).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
reportSnapshot,
|
||||
reportMetadata
|
||||
})
|
||||
expect(readFileSync(updated.record!.htmlPath!, 'utf8')).toContain('Mobile 03')
|
||||
expect(readFileSync(updated.record!.pngPath!).toString()).toBe('mobile-03')
|
||||
expect(JSON.parse(readFileSync(updated.record!.jsonPath!, 'utf8'))).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
reportSnapshot,
|
||||
reportMetadata
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy records viewable but rejects a lossless template switch', async () => {
|
||||
const legacyHtml = join(root, 'legacy.html')
|
||||
writeFileSync(legacyHtml, '<h1>Legacy</h1>')
|
||||
const { saveGeneratedReport, updateGeneratedReportTemplate } =
|
||||
await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'legacy-group',
|
||||
contactName: '旧报告',
|
||||
dateRange: '今天',
|
||||
messageCount: 5,
|
||||
generatedAt: '2026-08-12T11:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('legacy').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
|
||||
const updated = await updateGeneratedReportTemplate({
|
||||
reportId: saved.record!.id,
|
||||
templateId: 'desktop-editorial',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('new').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
success: false,
|
||||
error: '旧报告未保存结构化数据,无法无损切换模板'
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts and persists a render snapshot once for legacy template switching', async () => {
|
||||
const legacyHtml = join(root, 'legacy-with-html.html')
|
||||
writeFileSync(legacyHtml, '<h1>Legacy source</h1>')
|
||||
const { saveGeneratedReport, prepareGeneratedReportTemplateSwitch } =
|
||||
await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'legacy-snapshot-group',
|
||||
contactName: '旧报告快照',
|
||||
dateRange: '今天',
|
||||
messageCount: 8,
|
||||
generatedAt: '2026-08-12T12:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('legacy-snapshot').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
const extractSnapshot = vi.fn(async () => ({
|
||||
groupName: '旧报告快照',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '旧报告快照日报', TOPIC_CARDS: '<div>已有主题</div>' }
|
||||
}))
|
||||
|
||||
const first = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
|
||||
const second = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(extractSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8'))).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
reportRenderSnapshot: first.snapshot
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the classic v1 template as a selectable history template', async () => {
|
||||
const classicHtml = join(root, 'classic.html')
|
||||
writeFileSync(classicHtml, '<h1>经典日报</h1>')
|
||||
const { saveGeneratedReport } = await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'classic-group',
|
||||
contactName: '经典日报群',
|
||||
dateRange: '今天',
|
||||
messageCount: 6,
|
||||
generatedAt: '2026-08-12T13:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('classic').toString('base64')}`,
|
||||
htmlPath: classicHtml,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: 'v1'
|
||||
})
|
||||
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.record?.templateId).toBe('v1')
|
||||
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8')).templateId).toBe('v1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
import { switchGeneratedReportTemplate } from '../../src/renderer/src/utils/report-template-switch'
|
||||
|
||||
const structuredReport = {
|
||||
id: 'report-1',
|
||||
contactId: 'group-1',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
templateId: 'mobile-feed',
|
||||
reportSnapshot: { overview: '已有内容' },
|
||||
reportMetadata: { groupName: '测试群' }
|
||||
} as GeneratedReportRecord
|
||||
|
||||
describe('report template switching pipeline', () => {
|
||||
it('only exports the saved snapshot and updates the same history record', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,new',
|
||||
htmlPath: '/tmp/new.html',
|
||||
pngPath: '/tmp/new.png'
|
||||
})),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({
|
||||
success: true,
|
||||
record: { ...structuredReport, templateId: 'mobile-dashboard' as const }
|
||||
}))
|
||||
}
|
||||
|
||||
const result = await switchGeneratedReportTemplate(structuredReport, 'mobile-dashboard', api)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledTimes(1)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledWith({
|
||||
report: structuredReport.reportSnapshot,
|
||||
metadata: structuredReport.reportMetadata,
|
||||
templateId: 'mobile-dashboard'
|
||||
})
|
||||
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith({
|
||||
reportId: structuredReport.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
generatedImage: 'data:image/png;base64,new',
|
||||
htmlPath: '/tmp/new.html',
|
||||
pngPath: '/tmp/new.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('switches an existing report back to the classic default without rerunning AI', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,classic',
|
||||
htmlPath: '/tmp/classic.html',
|
||||
pngPath: '/tmp/classic.png'
|
||||
})),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({
|
||||
success: true,
|
||||
record: { ...structuredReport, templateId: 'v1' as const }
|
||||
}))
|
||||
}
|
||||
|
||||
const result = await switchGeneratedReportTemplate(structuredReport, 'v1', api)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledWith({
|
||||
report: structuredReport.reportSnapshot,
|
||||
metadata: structuredReport.reportMetadata,
|
||||
templateId: 'v1'
|
||||
})
|
||||
expect(api.prepareGeneratedReportTemplateSwitch).not.toHaveBeenCalled()
|
||||
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reportId: structuredReport.id, templateId: 'v1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('migrates a legacy record from its saved HTML before switching', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(),
|
||||
exportGroupReportSnapshot: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,legacy-new',
|
||||
htmlPath: '/tmp/legacy-new.html',
|
||||
pngPath: '/tmp/legacy-new.png'
|
||||
})),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
|
||||
success: true,
|
||||
snapshot: {
|
||||
groupName: '测试群',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '测试群日报' }
|
||||
}
|
||||
})),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
const result = await switchGeneratedReportTemplate(
|
||||
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
|
||||
'desktop-editorial',
|
||||
api
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).not.toHaveBeenCalled()
|
||||
expect(api.prepareGeneratedReportTemplateSwitch).toHaveBeenCalledWith('report-1')
|
||||
expect(api.exportGroupReportSnapshot).toHaveBeenCalledWith({
|
||||
snapshot: expect.objectContaining({ groupName: '测试群' }),
|
||||
templateId: 'desktop-editorial'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call any pipeline step when a legacy record cannot be migrated', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
|
||||
success: false,
|
||||
error: '当前日报缺少 HTML 文件,无法迁移旧模板数据'
|
||||
})),
|
||||
updateGeneratedReportTemplate: vi.fn()
|
||||
}
|
||||
const result = await switchGeneratedReportTemplate(
|
||||
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
|
||||
'desktop-editorial',
|
||||
api
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' })
|
||||
expect(api.exportGroupReport).not.toHaveBeenCalled()
|
||||
expect(api.updateGeneratedReportTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
getReportTemplate,
|
||||
isReportTemplateId,
|
||||
isSelectableReportTemplateId,
|
||||
REPORT_TEMPLATES,
|
||||
SELECTABLE_REPORT_TEMPLATES
|
||||
} from '../../src/shared/report-templates'
|
||||
|
||||
describe('daily report templates', () => {
|
||||
it('exposes the classic default plus exactly three mobile and two desktop product templates', () => {
|
||||
expect(REPORT_TEMPLATES).toHaveLength(5)
|
||||
expect(SELECTABLE_REPORT_TEMPLATES).toHaveLength(6)
|
||||
expect(SELECTABLE_REPORT_TEMPLATES[0]).toEqual(DEFAULT_REPORT_TEMPLATE)
|
||||
expect(DEFAULT_REPORT_TEMPLATE).toMatchObject({
|
||||
id: 'v1',
|
||||
label: '默认模板',
|
||||
name: '经典日报',
|
||||
resourceFile: 'mobile_daily_report_v1.html'
|
||||
})
|
||||
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')).toHaveLength(3)
|
||||
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')).toHaveLength(2)
|
||||
expect(new Set(REPORT_TEMPLATES.map((template) => template.cssClass)).size).toBe(5)
|
||||
})
|
||||
|
||||
it('uses mobile and desktop capture widths appropriate to their layouts', () => {
|
||||
expect(DEFAULT_REPORT_TEMPLATE.captureWidth).toBe(430)
|
||||
expect(DEFAULT_REPORT_TEMPLATE.maxCaptureWidth).toBeGreaterThanOrEqual(
|
||||
DEFAULT_REPORT_TEMPLATE.captureWidth
|
||||
)
|
||||
for (const template of REPORT_TEMPLATES) {
|
||||
expect(
|
||||
template.platform === 'mobile' ? template.captureWidth : template.captureWidth >= 1280
|
||||
).toBeTruthy()
|
||||
expect(template.maxCaptureWidth).toBeGreaterThanOrEqual(template.captureWidth)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves v1 and unknown ids to the classic default template', () => {
|
||||
expect(isReportTemplateId('desktop-editorial')).toBe(true)
|
||||
expect(isReportTemplateId('v1')).toBe(false)
|
||||
expect(isSelectableReportTemplateId('v1')).toBe(true)
|
||||
expect(isSelectableReportTemplateId('v2')).toBe(false)
|
||||
expect(getReportTemplate('unknown').id).toBe('v1')
|
||||
expect(getReportTemplate('v1')).toEqual(DEFAULT_REPORT_TEMPLATE)
|
||||
})
|
||||
|
||||
it('ships the classic resource and shared semantic resource with five distinct layout classes', () => {
|
||||
expect(existsSync(resolve('resources', DEFAULT_REPORT_TEMPLATE.resourceFile))).toBe(true)
|
||||
const resourcePath = resolve('resources', 'daily_report_templates.html')
|
||||
expect(existsSync(resourcePath)).toBe(true)
|
||||
const html = readFileSync(resourcePath, 'utf8')
|
||||
for (const template of REPORT_TEMPLATES) {
|
||||
expect(html).toContain(`.${template.cssClass}`)
|
||||
}
|
||||
expect(html).toContain('{{TOPIC_CARDS}}')
|
||||
expect(html).toContain('{{IMPORTANT_MESSAGES}}')
|
||||
expect(html).toContain('{{QA_CARDS}}')
|
||||
expect(html).toContain('{{RANK_ITEMS}}')
|
||||
expect(html).toContain('{{HERO_AVATARS}}')
|
||||
expect(html).not.toContain('群聊相册')
|
||||
expect(html).not.toContain('今日群相册')
|
||||
expect(html).toContain('grid-template-columns: minmax(78px, 104px) minmax(72px, 1fr) 34px')
|
||||
expect(html).toContain('.template-mobile-dashboard .heat-name {')
|
||||
expect(html).toContain('white-space: normal')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user