feat: v0.4.3 忽略所有空白比较选项与报告选项说明 - 新增忽略所有空白开关(行内空格/制表符/全角空格不参与判等、展示仍为原文、换行结构仍参与对比)、三种报告头部新增比较选项说明行(HTML 为 Hero 区徽标)
This commit is contained in:
@@ -94,7 +94,8 @@ export default function App(): ReactElement {
|
||||
const [options, setOptions] = useState<DiffOptions>({
|
||||
trimWhitespace: false,
|
||||
ignoreCase: false,
|
||||
ignoreBlankLines: false
|
||||
ignoreBlankLines: false,
|
||||
ignoreAllWhitespace: false
|
||||
})
|
||||
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||
const [onlyDiff, setOnlyDiff] = useState(false)
|
||||
@@ -319,7 +320,8 @@ export default function App(): ReactElement {
|
||||
leftEncoding: paneL?.meta.encoding ?? '',
|
||||
rightEncoding: paneR?.meta.encoding ?? '',
|
||||
leftPath: paneL?.path || undefined,
|
||||
rightPath: paneR?.path || undefined
|
||||
rightPath: paneR?.path || undefined,
|
||||
options
|
||||
}
|
||||
const content = buildReport(rows, ctx, summary, fmt)
|
||||
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
|
||||
@@ -338,7 +340,7 @@ export default function App(): ReactElement {
|
||||
})
|
||||
}
|
||||
},
|
||||
[diff, rows, paneL, paneR, summary, showToast]
|
||||
[diff, rows, paneL, paneR, summary, options, showToast]
|
||||
)
|
||||
|
||||
const anyPane = paneL !== null || paneR !== null
|
||||
|
||||
@@ -490,6 +490,51 @@ describe('App - 仅看差异视图', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - 忽略所有空白', () => {
|
||||
it('开启后仅行内空白不同的两侧文本判为完全一致', async () => {
|
||||
window.api = mockApi({
|
||||
openFile: async (side) =>
|
||||
side === 'left'
|
||||
? { path: '/tmp/a.js', name: 'a.js', text: 'const x = 1;\nif (x) {\n doSomething( x )\n}', encoding: 'UTF-8', binary: false }
|
||||
: { path: '/tmp/b.js', name: 'b.js', text: 'const x = 1;\nif(x){\n\tdoSomething(x)\n}', encoding: 'UTF-8', binary: false }
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByText('打开右侧'))
|
||||
await screen.findAllByText('b.js')
|
||||
// 默认严格对比:报差异
|
||||
expect(screen.getByText('有差异')).toBeInTheDocument()
|
||||
// 开启忽略所有空白后:判为完全一致
|
||||
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||
expect(await screen.findByText('两文件内容完全一致')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('导出报告携带比较选项说明', async () => {
|
||||
let saved = ''
|
||||
window.api = mockApi({
|
||||
openFile: async () => ({
|
||||
path: '/tmp/a.txt',
|
||||
name: 'a.txt',
|
||||
text: 'line1\nline2',
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
}),
|
||||
saveReport: async (content: string) => {
|
||||
saved = content
|
||||
return { ok: true, path: 'C:/docs/r.txt' }
|
||||
}
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('打开左侧'))
|
||||
await screen.findByText('导出报告')
|
||||
fireEvent.click(screen.getByText('导出报告'))
|
||||
fireEvent.click(screen.getByText('纯文本'))
|
||||
await screen.findByText(/报告已保存/)
|
||||
expect(saved).toContain('比较选项:无(严格逐字符对比)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
||||
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -13,7 +13,7 @@ function setup(extra?: { computing?: boolean }) {
|
||||
const onSwap = vi.fn()
|
||||
const utils = render(
|
||||
<Toolbar
|
||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
|
||||
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false }}
|
||||
onOptionsChange={onOptionsChange}
|
||||
summary={summary}
|
||||
navIndex={0}
|
||||
@@ -40,19 +40,25 @@ describe('Toolbar', () => {
|
||||
it('切换忽略大小写触发 onOptionsChange', () => {
|
||||
const { onOptionsChange } = setup()
|
||||
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false })
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
||||
})
|
||||
|
||||
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
||||
const { onOptionsChange } = setup()
|
||||
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false })
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: false })
|
||||
})
|
||||
|
||||
it('切换忽略空行触发 onOptionsChange', () => {
|
||||
const { onOptionsChange } = setup()
|
||||
fireEvent.click(screen.getByLabelText('忽略空行'))
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true })
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true, ignoreAllWhitespace: false })
|
||||
})
|
||||
|
||||
it('切换忽略所有空白触发 onOptionsChange', () => {
|
||||
const { onOptionsChange } = setup()
|
||||
fireEvent.click(screen.getByLabelText('忽略所有空白'))
|
||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false, ignoreAllWhitespace: true })
|
||||
})
|
||||
|
||||
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
||||
|
||||
@@ -76,6 +76,17 @@ export default function Toolbar({
|
||||
/>
|
||||
忽略空行
|
||||
</label>
|
||||
<label
|
||||
className="switch"
|
||||
title="忽略行内全部空白(空格/制表符/全角空格),仅比较实际文字;换行结构仍参与对比"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.ignoreAllWhitespace ?? false}
|
||||
onChange={(e) => set({ ignoreAllWhitespace: e.target.checked })}
|
||||
/>
|
||||
忽略所有空白
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
|
||||
@@ -172,6 +172,76 @@ describe('computeDiff - 超长行词级防护', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 忽略所有空白', () => {
|
||||
it('行内空格差异不再产生差异', () => {
|
||||
const { summary } = computeDiff('a b c', 'abc', { ignoreAllWhitespace: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('制表符与全角空格同样被忽略', () => {
|
||||
expect(computeDiff('a\tb', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
|
||||
expect(computeDiff('a b', 'ab', { ignoreAllWhitespace: true }).summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('开启后行首尾空白差异自然被覆盖(trim 子集)', () => {
|
||||
const { summary } = computeDiff(' value ', 'value', { ignoreAllWhitespace: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('缩进与符号间空白差异判等(代码场景)', () => {
|
||||
const { summary } = computeDiff(
|
||||
' if (x) { return 1; }',
|
||||
'if(x){return 1;}',
|
||||
{ ignoreAllWhitespace: true }
|
||||
)
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('多行文本仅行内空白不同判为一致', () => {
|
||||
const { summary } = computeDiff('a b\nc d\ne', 'ab\nc d\ne', { ignoreAllWhitespace: true })
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('实际文字差异仍被识别且保留词级高亮', () => {
|
||||
const { rows, summary } = computeDiff('a b', 'a c', { ignoreAllWhitespace: true })
|
||||
expect(summary.modified).toBe(1)
|
||||
const m = rows.find((r) => r.rowKind === 'modified')!
|
||||
expect(m.left.segs).not.toBeNull()
|
||||
expect(m.right.segs).not.toBeNull()
|
||||
})
|
||||
|
||||
it('展示文本始终为原始行(不因归一化而改写)', () => {
|
||||
const { rows } = computeDiff(' a b ', 'a b', { ignoreAllWhitespace: true })
|
||||
expect(rows[0].left.text).toBe(' a b ')
|
||||
expect(rows[0].right.text).toBe('a b')
|
||||
})
|
||||
|
||||
it('换行结构差异不受该选项影响(行级对比边界)', () => {
|
||||
const { summary } = computeDiff('ab cd', 'ab\ncd', { ignoreAllWhitespace: true })
|
||||
expect(summary.changedLines).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('空行数量差异不受该选项影响(需配合忽略空行)', () => {
|
||||
const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true })
|
||||
expect(summary.deleted).toBe(1)
|
||||
const both = computeDiff('a\n\nb', 'a\nb', { ignoreAllWhitespace: true, ignoreBlankLines: true })
|
||||
expect(both.summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('与忽略大小写组合生效', () => {
|
||||
const { summary } = computeDiff('A B', 'ab', {
|
||||
ignoreAllWhitespace: true,
|
||||
ignoreCase: true
|
||||
})
|
||||
expect(summary.changedLines).toBe(0)
|
||||
})
|
||||
|
||||
it('未开启时行内空格差异仍被识别', () => {
|
||||
const { summary } = computeDiff('a b c', 'abc')
|
||||
expect(summary.changedLines).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDiff - 行切分细节', () => {
|
||||
it('结尾换行不产生多余空行', () => {
|
||||
const { rows, summary } = computeDiff('a\n', 'a')
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface DiffOptions {
|
||||
ignoreCase?: boolean
|
||||
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
|
||||
ignoreBlankLines?: boolean
|
||||
/** 忽略全部行内空白(空格/制表符/全角空格等),仅比较实际文字内容;换行结构仍参与对比 */
|
||||
ignoreAllWhitespace?: boolean
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
@@ -94,7 +96,7 @@ export function splitLines(text: string): string[] {
|
||||
* 计算文本差异。
|
||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
|
||||
* trimWhitespace / ignoreCase / ignoreAllWhitespace 只影响“是否判等”,展示文本始终取原始行;
|
||||
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
||||
*/
|
||||
export function computeDiff(
|
||||
@@ -117,7 +119,9 @@ export function computeDiff(
|
||||
: rightSeq
|
||||
|
||||
const norm = (s: string): string => {
|
||||
let v = options.trimWhitespace ? s.trim() : s
|
||||
// 忽略所有空白为 trimWhitespace 的超集:开启后行内空白一并剔除,trim 不再重复处理
|
||||
let v = options.ignoreAllWhitespace ? s.replace(/\s+/g, '') : s
|
||||
if (!options.ignoreAllWhitespace && options.trimWhitespace) v = v.trim()
|
||||
if (options.ignoreCase) v = v.toLowerCase()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildReport,
|
||||
buildSegments,
|
||||
plainSummary,
|
||||
plainOptions,
|
||||
REPORT_EXT
|
||||
} from './report'
|
||||
|
||||
@@ -164,6 +165,55 @@ describe('buildHtmlReport', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plainOptions - 比较选项描述', () => {
|
||||
it('未传或空选项时说明为严格对比', () => {
|
||||
expect(plainOptions(undefined)).toBe('无(严格逐字符对比)')
|
||||
expect(plainOptions({})).toBe('无(严格逐字符对比)')
|
||||
})
|
||||
|
||||
it('列出全部生效选项', () => {
|
||||
expect(
|
||||
plainOptions({ ignoreAllWhitespace: true, ignoreCase: true, ignoreBlankLines: true })
|
||||
).toBe('忽略所有空白、忽略大小写、忽略空行')
|
||||
expect(plainOptions({ trimWhitespace: true, ignoreCase: true })).toBe(
|
||||
'忽略行首尾空白、忽略大小写'
|
||||
)
|
||||
})
|
||||
|
||||
it('忽略所有空白遮蔽行首尾空白(超集不重复列出)', () => {
|
||||
expect(plainOptions({ ignoreAllWhitespace: true, trimWhitespace: true })).toBe('忽略所有空白')
|
||||
})
|
||||
})
|
||||
|
||||
describe('报告头部比较选项说明', () => {
|
||||
const { rows, summary } = computeDiff('a b', 'ab', { ignoreAllWhitespace: true })
|
||||
const optCtx = {
|
||||
...ctx,
|
||||
options: { ignoreAllWhitespace: true, ignoreCase: true }
|
||||
}
|
||||
|
||||
it('纯文本头部包含比较选项行', () => {
|
||||
const txt = buildTxtReport(rows, optCtx, summary)
|
||||
expect(txt).toContain('比较选项:忽略所有空白、忽略大小写')
|
||||
})
|
||||
|
||||
it('Markdown 头部包含比较选项行(引用块内)', () => {
|
||||
const md = buildMarkdownReport(rows, optCtx, summary)
|
||||
expect(md).toContain('> 比较选项:忽略所有空白、忽略大小写')
|
||||
})
|
||||
|
||||
it('HTML Hero 区包含比较选项徽标', () => {
|
||||
const html = buildHtmlReport(rows, optCtx, summary)
|
||||
expect(html).toContain('hero-opts')
|
||||
expect(html).toContain('比较选项:忽略所有空白、忽略大小写')
|
||||
})
|
||||
|
||||
it('未传选项时头部说明为严格对比', () => {
|
||||
const txt = buildTxtReport(rows, ctx, summary)
|
||||
expect(txt).toContain('比较选项:无(严格逐字符对比)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTxtReport', () => {
|
||||
it('输出带增删改标记与人话结论的行式文本', () => {
|
||||
const { rows, summary } = computeDiff('a', 'a\nb')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DiffRow, DiffSummary, SideCell } from './diffEngine'
|
||||
import { DiffOptions, DiffRow, DiffSummary, SideCell } from './diffEngine'
|
||||
|
||||
export type ReportFormat = 'html' | 'txt' | 'md'
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ReportContext {
|
||||
leftPath?: string
|
||||
/** 右侧文件完整路径(可选) */
|
||||
rightPath?: string
|
||||
/** 本次对比生效的比较选项(用于报告头部说明,可选) */
|
||||
options?: DiffOptions
|
||||
}
|
||||
|
||||
const esc = (s: string): string =>
|
||||
@@ -116,6 +118,17 @@ export function plainSummary(summary: DiffSummary): string {
|
||||
return `共 ${summary.changedLines} 处不同:${parts.join('、')}`
|
||||
}
|
||||
|
||||
/** 已生效比较选项的人话描述(报告头部展示;无选项生效时说明为严格对比) */
|
||||
export function plainOptions(options?: DiffOptions): string {
|
||||
const on: string[] = []
|
||||
// 忽略所有空白为行首尾空白的超集,同时开启时只列前者,避免误导
|
||||
if (options?.ignoreAllWhitespace) on.push('忽略所有空白')
|
||||
else if (options?.trimWhitespace) on.push('忽略行首尾空白')
|
||||
if (options?.ignoreCase) on.push('忽略大小写')
|
||||
if (options?.ignoreBlankLines) on.push('忽略空行')
|
||||
return on.length > 0 ? on.join('、') : '无(严格逐字符对比)'
|
||||
}
|
||||
|
||||
const headLines = (
|
||||
ctx: ReportContext,
|
||||
summary: DiffSummary,
|
||||
@@ -128,6 +141,7 @@ const headLines = (
|
||||
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
||||
}`,
|
||||
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||
`比较选项:${plainOptions(ctx.options)}`,
|
||||
`结论:${plainSummary(summary)}`,
|
||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
||||
]
|
||||
@@ -165,6 +179,11 @@ const REPORT_CSS = `
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||
}
|
||||
.hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; }
|
||||
.hero-opts {
|
||||
color:#74809a; font-size:12px; margin:0 0 10px;
|
||||
padding:4px 10px; border-radius:8px; display:inline-block;
|
||||
background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.14);
|
||||
}
|
||||
.verdict {
|
||||
font-size:16px; font-weight:700; color:#f6bd87;
|
||||
padding:8px 14px; border-radius:10px; margin-bottom:14px;
|
||||
@@ -311,7 +330,8 @@ const REPORT_CSS = `
|
||||
@media print {
|
||||
body { background:#fff; color:#111; padding:0; }
|
||||
.hero { border-color:#ccc; background:#fafafa; box-shadow:none; }
|
||||
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
|
||||
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
|
||||
.hero-opts { background:#fafafa; border-color:#ddd; color:#666; }
|
||||
.verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
|
||||
.verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
|
||||
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
|
||||
@@ -453,6 +473,7 @@ export function buildHtmlReport(
|
||||
<header class="hero">
|
||||
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
||||
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
||||
<div class="hero-opts">比较选项:${esc(plainOptions(ctx.options))}</div>
|
||||
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
||||
<div class="files">
|
||||
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
||||
|
||||
Reference in New Issue
Block a user