fix: 比较选项联动可视化——禁用开关灰显样式、已包含徽标与点击解释、字符级降级标记(0.5.4)
This commit is contained in:
@@ -571,6 +571,10 @@ export default function App(): ReactElement {
|
||||
onlyDiff={onlyDiff}
|
||||
onOnlyDiffChange={setOnlyDiff}
|
||||
onSwap={swapPanes}
|
||||
onOptionBlocked={(label) =>
|
||||
showToast(`「${label}」已包含在字符级对比中,关闭字符级后可单独设置`)
|
||||
}
|
||||
charModeDowngraded={diff?.charModeDowngraded ?? false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -713,12 +713,44 @@ describe('App - 字符级对比', () => {
|
||||
await screen.findByText('导出报告')
|
||||
expect(screen.getByLabelText('忽略行首尾空白')).toBeEnabled()
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled()
|
||||
expect(screen.getByLabelText('忽略空行')).toBeDisabled()
|
||||
expect(screen.getByLabelText('忽略所有空白')).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略空行/)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略所有空白/)).toBeDisabled()
|
||||
expect(screen.getByLabelText('忽略大小写')).toBeEnabled()
|
||||
})
|
||||
|
||||
// 字符级开启时空白选项 label 带“已包含”徽标,getByLabelText 需正则前缀匹配
|
||||
it('先勾选空白选项再开启字符级:灰显勾选+已包含徽标,关闭后恢复原状态', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
// 用户先勾选“忽略所有空白”与“忽略空行”
|
||||
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||
fireEvent.click(screen.getByLabelText('忽略空行'))
|
||||
expect(screen.getByLabelText('忽略所有空白')).toBeChecked()
|
||||
// 开启字符级:三个空白选项全部灰显勾选(语义已包含)并带徽标
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeChecked()
|
||||
expect(screen.getByLabelText(/^忽略空行/)).toBeChecked()
|
||||
expect(screen.getByLabelText(/^忽略所有空白/)).toBeChecked()
|
||||
expect(screen.getAllByText('已包含')).toHaveLength(3)
|
||||
// 关闭字符级:回到用户自己的勾选状态(忽略所有空白与忽略空行仍勾选,行首尾空白未勾选)
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
expect(screen.getByLabelText('忽略行首尾空白')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('忽略空行')).toBeChecked()
|
||||
expect(screen.getByLabelText('忽略所有空白')).toBeChecked()
|
||||
expect(screen.queryByText('已包含')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('点击被字符级包含的空白选项弹出解释提示', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
fireEvent.click(screen.getByLabelText(/^忽略所有空白/))
|
||||
expect(await screen.findByText(/「忽略所有空白」已包含在字符级对比中/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('内容超限时自动降级行级并给出提示', async () => {
|
||||
// 1900 行 × 106 字符 ≈ 201,400 归一化字符(超过 20 万阈值);
|
||||
// 总行数 1901 未超过快路径阈值,同步计算即时降级
|
||||
@@ -737,6 +769,8 @@ describe('App - 字符级对比', () => {
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByLabelText('字符级对比'))
|
||||
expect(await screen.findByText(/已自动降级为行级对比/)).toBeInTheDocument()
|
||||
// 降级状态在工具栏可见:字符级开关旁出现“已降级”标记
|
||||
expect(await screen.findByText('已降级')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('导出报告携带字符级对比说明', async () => {
|
||||
|
||||
@@ -5,7 +5,13 @@ import { DiffSummary } from '../diff/diffEngine'
|
||||
|
||||
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
||||
|
||||
function setup(extra?: { computing?: boolean; charMode?: boolean }) {
|
||||
function setup(extra?: {
|
||||
computing?: boolean
|
||||
charMode?: boolean
|
||||
charModeDowngraded?: boolean
|
||||
baseOptions?: Partial<import('../diff/diffEngine').DiffOptions>
|
||||
onOptionBlocked?: (label: string) => void
|
||||
}) {
|
||||
const onOptionsChange = vi.fn()
|
||||
const onNav = vi.fn()
|
||||
const onExport = vi.fn()
|
||||
@@ -13,7 +19,14 @@ function setup(extra?: { computing?: boolean; charMode?: boolean }) {
|
||||
const onSwap = vi.fn()
|
||||
const utils = render(
|
||||
<Toolbar
|
||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: extra?.charMode ?? false }}
|
||||
options={{
|
||||
trimWhitespace: false,
|
||||
ignoreCase: false,
|
||||
ignoreBlankLines: false,
|
||||
ignoreAllWhitespace: false,
|
||||
...extra?.baseOptions,
|
||||
charMode: extra?.charMode ?? false
|
||||
}}
|
||||
onOptionsChange={onOptionsChange}
|
||||
summary={summary}
|
||||
navIndex={0}
|
||||
@@ -24,6 +37,8 @@ function setup(extra?: { computing?: boolean; charMode?: boolean }) {
|
||||
onlyDiff={false}
|
||||
onOnlyDiffChange={onOnlyDiffChange}
|
||||
onSwap={onSwap}
|
||||
onOptionBlocked={extra?.onOptionBlocked}
|
||||
charModeDowngraded={extra?.charModeDowngraded ?? false}
|
||||
/>
|
||||
)
|
||||
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
|
||||
@@ -67,11 +82,62 @@ describe('Toolbar', () => {
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false, charMode: true })
|
||||
})
|
||||
|
||||
// 字符级开启时空白选项 label 带有“已包含”徽标,getByLabelText 需正则前缀匹配
|
||||
it('字符级对比开启时三个空白选项被禁用(语义已被包含)', () => {
|
||||
setup({ charMode: true })
|
||||
expect(screen.getByLabelText('忽略行首尾空白')).toBeDisabled()
|
||||
expect(screen.getByLabelText('忽略空行')).toBeDisabled()
|
||||
expect(screen.getByLabelText('忽略所有空白')).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略空行/)).toBeDisabled()
|
||||
expect(screen.getByLabelText(/^忽略所有空白/)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('字符级对比开启时空白选项灰显勾选(语义已生效)并渲染“已包含”徽标', () => {
|
||||
const { container } = setup({ charMode: true })
|
||||
// 灰显勾选:checked 仅为视觉呈现,表达语义已随字符级生效
|
||||
expect(screen.getByLabelText(/^忽略行首尾空白/)).toBeChecked()
|
||||
expect(screen.getByLabelText(/^忽略空行/)).toBeChecked()
|
||||
expect(screen.getByLabelText(/^忽略所有空白/)).toBeChecked()
|
||||
// 徽标常显(无需悬停 title)
|
||||
expect(screen.getAllByText('已包含')).toHaveLength(3)
|
||||
expect(container.querySelectorAll('.switch.disabled')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('字符级对比关闭时空白选项恢复用户原状态且不渲染徽标', () => {
|
||||
const { container } = setup({ baseOptions: { ignoreBlankLines: true } })
|
||||
// 用户原状态:仅忽略空行勾选
|
||||
expect(screen.getByLabelText('忽略行首尾空白')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('忽略空行')).toBeChecked()
|
||||
expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked()
|
||||
expect(screen.queryByText('已包含')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.switch.disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('点击被包含的空白选项触发 onOptionBlocked 且不改写选项状态', () => {
|
||||
const onOptionBlocked = vi.fn()
|
||||
const { onOptionsChange } = setup({ charMode: true, onOptionBlocked })
|
||||
// 点击 label 本身(贴近真实用户点文字的行为;onClick preventDefault 阻止激活转发的干扰)
|
||||
const label = screen.getByLabelText(/^忽略所有空白/).closest('label') as HTMLElement
|
||||
fireEvent.click(label)
|
||||
expect(onOptionBlocked).toHaveBeenCalledWith('忽略所有空白')
|
||||
expect(onOptionsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('字符级对比关闭时点击空白选项不触发 onOptionBlocked', () => {
|
||||
const onOptionBlocked = vi.fn()
|
||||
setup({ onOptionBlocked })
|
||||
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||
expect(onOptionBlocked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('字符级因内容超限降级时开关旁显示“已降级”标记', () => {
|
||||
setup({ charMode: true, charModeDowngraded: true })
|
||||
expect(screen.getByText('已降级')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('未降级或字符级关闭时不显示“已降级”标记', () => {
|
||||
setup({ charMode: true })
|
||||
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||
setup({ charModeDowngraded: true })
|
||||
expect(screen.queryByText('已降级')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('字符级对比开启时忽略大小写仍可用', () => {
|
||||
|
||||
@@ -24,6 +24,10 @@ interface ToolbarProps {
|
||||
onOnlyDiffChange: (v: boolean) => void
|
||||
/** 交换左右两侧 */
|
||||
onSwap: () => void
|
||||
/** 点击被字符级对比包含(禁用中)的空白选项时通知(App 弹 toast 解释) */
|
||||
onOptionBlocked?: (label: string) => void
|
||||
/** 字符级对比因内容超限自动降级为行级(开关旁显示“已降级”标记) */
|
||||
charModeDowngraded?: boolean
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
@@ -37,7 +41,9 @@ export default function Toolbar({
|
||||
computing,
|
||||
onlyDiff,
|
||||
onOnlyDiffChange,
|
||||
onSwap
|
||||
onSwap,
|
||||
onOptionBlocked,
|
||||
charModeDowngraded = false
|
||||
}: ToolbarProps): ReactElement {
|
||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -48,25 +54,37 @@ export default function Toolbar({
|
||||
|
||||
const canNav = navCount > 0
|
||||
|
||||
// 被包含选项的 label:灰显 + 点击解释(checkbox 本身 disabled 不响应,label 捕获点击给反馈)
|
||||
const blockedLabel = (label: string): ((e: { preventDefault(): void }) => void) | undefined =>
|
||||
options.charMode
|
||||
? (e) => {
|
||||
// 阻止 label 默认激活行为,统一走解释反馈
|
||||
e.preventDefault()
|
||||
onOptionBlocked?.(label)
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="tool-group">
|
||||
<span className="tool-label">比较选项</span>
|
||||
<label
|
||||
className="switch"
|
||||
className={'switch' + (options.charMode ? ' disabled' : '')}
|
||||
title={
|
||||
options.charMode
|
||||
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||
: '忽略行首尾空白差异'
|
||||
}
|
||||
onClick={blockedLabel('忽略行首尾空白')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.trimWhitespace}
|
||||
checked={options.charMode ? true : options.trimWhitespace}
|
||||
disabled={options.charMode}
|
||||
onChange={(e) => set({ trimWhitespace: e.target.checked })}
|
||||
/>
|
||||
忽略行首尾空白
|
||||
{options.charMode && <span className="opt-badge">已包含</span>}
|
||||
</label>
|
||||
<label className="switch">
|
||||
<input
|
||||
@@ -77,36 +95,40 @@ export default function Toolbar({
|
||||
忽略大小写
|
||||
</label>
|
||||
<label
|
||||
className="switch"
|
||||
className={'switch' + (options.charMode ? ' disabled' : '')}
|
||||
title={
|
||||
options.charMode
|
||||
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||
: '忽略空行差异(两侧空白行互相判等)'
|
||||
}
|
||||
onClick={blockedLabel('忽略空行')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.ignoreBlankLines ?? false}
|
||||
checked={options.charMode ? true : (options.ignoreBlankLines ?? false)}
|
||||
disabled={options.charMode}
|
||||
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
|
||||
/>
|
||||
忽略空行
|
||||
{options.charMode && <span className="opt-badge">已包含</span>}
|
||||
</label>
|
||||
<label
|
||||
className="switch"
|
||||
className={'switch' + (options.charMode ? ' disabled' : '')}
|
||||
title={
|
||||
options.charMode
|
||||
? '已被“字符级对比”包含(字符级忽略全部空白与换行)'
|
||||
: '忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比'
|
||||
}
|
||||
onClick={blockedLabel('忽略所有空白')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.ignoreAllWhitespace ?? false}
|
||||
checked={options.charMode ? true : (options.ignoreAllWhitespace ?? false)}
|
||||
disabled={options.charMode}
|
||||
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
|
||||
/>
|
||||
忽略所有空白
|
||||
{options.charMode && <span className="opt-badge">已包含</span>}
|
||||
</label>
|
||||
<label
|
||||
className="switch"
|
||||
@@ -118,6 +140,11 @@ export default function Toolbar({
|
||||
onChange={(e) => set({ charMode: e.target.checked })}
|
||||
/>
|
||||
字符级对比
|
||||
{options.charMode && charModeDowngraded && (
|
||||
<span className="opt-badge warn" title="内容较大,本次对比已自动降级为行级(忽略所有空白 + 忽略空行语义)">
|
||||
已降级
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -465,6 +465,35 @@ button:disabled {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* 被字符级对比包含的选项:整行灰显 + 不可点光标(勾选仅为视觉呈现,语义已随字符级生效) */
|
||||
.switch.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.switch.disabled input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 选项状态徽标:已包含 / 已降级 */
|
||||
.opt-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-badge.warn {
|
||||
color: var(--mod-fg);
|
||||
background: var(--mod-bg);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* 导航按钮 */
|
||||
.nav-btn {
|
||||
width: 28px;
|
||||
|
||||
Reference in New Issue
Block a user