import type { BlockView } from '../blockView' import type { SourcedBlocks } from './blockDiff' import { describe, expect, it } from 'vitest' import { readBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { runtime, WASM_DIR } from '../testSupport' import { pythonZhTranslator } from '../translators/pythonZh' import { alignSiblings, diffBlockTrees, similarity } from './blockDiff' const py = createPythonAdapter(WASM_DIR) async function python(source: string): Promise { const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source) return { blocks, source } } /** 手写的一块:只填比对用得到的几样。 */ function view(anchor: string, kind: string, code: string, extra: Partial = {}): BlockView { return { id: anchor, anchor, kind, startLine: 0, endLine: 0, headerEndLine: 0, code, codeTokens: [], attributes: { kind }, children: [], translation: [{ text: code, role: 'plain' }], ...extra, } as BlockView } /** 名字写在源码开头:defines 指向它。 */ function named(anchor: string, kind: string, name: string, code: string, source: { text: string }): BlockView { const startIndex = source.text.length source.text += name return view(anchor, kind, code, { defines: { startIndex, endIndex: startIndex + name.length } }) } const RELEASE_LUA = `_RELEASE_LUA = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """` const BEFORE = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再删.""" from __future__ import annotations import uuid from typing import Any ${RELEASE_LUA} async def try_acquire(redis: Any, key: str, ttl_sec: int) -> bool: """抢锁; 抢到返回持有者 token, 已被占用返回 None.""" token = uuid.uuid4().hex ok = await redis.set(key, token, nx=True, ex=ttl_sec) return token if ok else None async def _is_owner(redis: Any, key: str, token: str) -> bool: """锁仍归 token 时返回真.""" return await redis.get(key) == token async def release(redis: Any, key: str, token: str) -> bool: """只在锁仍归 token 时删掉; 返回是否真的删了.""" result = await redis.eval(_RELEASE_LUA, 1, key) return int(result) == 1 ` const AFTER = `"""Redis 互斥锁: SET NX EX 抢锁, Lua 比对 token 再续期 / 再删.""" from __future__ import annotations import uuid from typing import Any ${RELEASE_LUA} _RENEW_LUA = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end """ async def try_acquire(redis: Any, key: str, ttl_sec: int, token_prefix: str = "") -> str | None: """抢锁; 抢到返回持有者 token, 已被占用返回 None.""" token = f"{token_prefix}{uuid.uuid4().hex}" ok = await redis.set(key, token, nx=True, ex=ttl_sec) return token if ok else None async def renew(redis: Any, key: str, token: str, ttl_sec: int) -> bool: """只在锁仍归 token 时把 TTL 续回 ttl_sec; 返回是否续上.""" result = await redis.eval(_RENEW_LUA, 1, key, token, str(ttl_sec)) return int(result) == 1 async def release(redis: Any, key: str, token: str) -> bool: """只在锁仍归 token 时删掉; 返回是否真的删了.""" result = await redis.eval(_RELEASE_LUA, 1, key, token) return int(result) == 1 ` describe('similarity', () => { it('一样是 1,毫不相干是 0,改了一个词在中间', () => { expect(similarity('a = 1', 'a = 1')).toBe(1) expect(similarity('foo(bar)', 'x')).toBe(0) const score = similarity('print(a, b)', 'print(a, c)') expect(score).toBeGreaterThan(0.5) expect(score).toBeLessThan(1) }) }) describe('alignSiblings', () => { it('有名字的按种类 + 名字配,挪了位置也配得上;改名算一删一增', () => { const source = { text: '' } const before = [named('f', 'function', 'f', 'def f(): pass', source), named('g', 'function', 'g', 'def g(): pass', source)] const after = [named('g2', 'function', 'g', 'def g(): return 1', source), named('h', 'function', 'h', 'def f(): pass', source)] const { toOld, toNew } = alignSiblings(before, source.text, after, source.text) expect(toOld).toEqual([1, undefined]) expect(toNew).toEqual([undefined, 0]) }) it('同名的照先后配', () => { const source = { text: '' } const before = [named('a', 'function', 'f', 'one', source), named('b', 'function', 'f', 'two', source)] const after = [named('c', 'function', 'f', 'one', source), named('d', 'function', 'f', 'two!', source)] expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([0, 1]) }) it('种类不同的同名块不配', () => { const source = { text: '' } const before = [named('a', 'const', 'x', 'const x = 1', source)] const after = [named('b', 'variable', 'x', 'let x = 1', source)] expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) }) it('没名字的:源码没变的按最长公共子序列配,中间的按相似度配成修改,不像的算一删一增', () => { const before = [view('1', 'statement', 'print(a)'), view('2', 'statement', 'call(alpha, beta, gamma)'), view('3', 'statement', 'print(b)')] const after = [ view('1', 'statement', 'print(a)'), view('x', 'statement', 'totally_new()'), view('2', 'statement', 'call(alpha, beta, delta)'), view('3', 'statement', 'print(b)'), ] expect(alignSiblings(before, '', after, '').toOld).toEqual([0, undefined, 1, 2]) }) it('没名字的块种类不同就不按相似度配', () => { const before = [view('1', 'statement', 'call(alpha, beta)')] const after = [view('2', 'return', 'call(alpha, beta, gamma)')] expect(alignSiblings(before, '', after, '').toOld).toEqual([undefined]) }) it('有名字的块不按相似度配:改名算一删一增', () => { const source = { text: '' } const before = [named('a', 'function', 'old_name', 'def old_name(x): return x + 1', source)] const after = [named('b', 'function', 'new_name', 'def new_name(x): return x + 1', source)] expect(alignSiblings(before, source.text, after, source.text).toOld).toEqual([undefined]) }) }) describe('diffBlockTrees', () => { it('原型的 mutex.py:新增 2 · 修改 3 · 删除 1,只数最外层', async () => { const diff = diffBlockTrees(await python(BEFORE), await python(AFTER)) expect(diff.stats).toEqual({ add: 2, mod: 3, del: 1 }) }) it('新增的块连同子孙都算新增,只有最外面那块挂小标', async () => { const after = await python(AFTER) const diff = diffBlockTrees(await python(BEFORE), after) const renew = after.blocks.find(block => block.anchor === 'function renew')! expect(diff.blocks[renew.anchor]).toEqual({ change: 'add', outermost: true }) for (const child of renew.children) expect(diff.blocks[child.anchor]).toEqual({ change: 'add' }) }) it('修改的块:自己的内容改了带标记,子块照样比;只有子块改了的父块不算自己改了', async () => { const after = await python(AFTER) const diff = diffBlockTrees(await python(BEFORE), after) const tryAcquire = after.blocks.find(block => block.anchor === 'function try_acquire')! expect(diff.blocks[tryAcquire.anchor]).toMatchObject({ change: 'mod', own: true }) expect(diff.blocks[tryAcquire.anchor].marks?.rows).toBeDefined() const [token, ok, back] = tryAcquire.children expect(diff.blocks[token.anchor]).toMatchObject({ change: 'mod', own: true }) expect(diff.blocks[ok.anchor]).toBeUndefined() expect(diff.blocks[back.anchor]).toBeUndefined() const release = after.blocks.find(block => block.anchor === 'function release')! expect(diff.blocks[release.anchor]).toEqual({ change: 'mod' }) expect(diff.blocks[release.children[0].anchor]).toMatchObject({ change: 'mod', own: true }) }) it('删掉的块放回原来的位置:前面配上的兄弟之后,再越过紧跟着的新增块;只带它自己的内容', async () => { const after = await python(AFTER) const diff = diffBlockTrees(await python(BEFORE), after) expect(diff.removed).toHaveLength(1) const [removed] = diff.removed expect(removed).toMatchObject({ parent: '', after: 'function renew' }) expect(removed.block.anchor).toBe('function _is_owner') expect(removed.block.children).toEqual([]) expect(removed.block.defines).toBeUndefined() const renew = after.blocks.find(block => block.anchor === 'function renew')! expect(removed.line).toBe(renew.endLine + 1) const names = JSON.stringify(removed.block) expect(names).not.toContain('"offset"') }) it('没变的文件没有改动', async () => { const before = await python(BEFORE) expect(diffBlockTrees(before, await python(BEFORE))).toEqual({ blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 } }) }) it('子块里删掉的块挂在父块下面,排在最前面时没有 after', async () => { const before = await python('def f():\n a = 1\n b = 2\n') const after = await python('def f():\n b = 2\n') const diff = diffBlockTrees(before, after) expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 }) expect(diff.removed).toHaveLength(1) expect(diff.removed[0]).toMatchObject({ parent: 'function f', line: 1 }) expect(diff.removed[0].after).toBeUndefined() }) }) describe('改了挂在块上的注释', () => { it('改的是讲这块的注释:这块算修改、自己改了,「注释」一行逐词标;一行译文的卡片标在它下面那几行(rows)上', async () => { const before = await python('import os\n\n# 讲 load\ndef load(path):\n # 先检查\n return path\n') const after = await python('import os\n\n# 讲 load,改过\ndef load(path):\n # 先检查一下\n return path\n') const diff = diffBlockTrees(before, after) const [, load] = after.blocks const [returned] = load.children expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 }) expect(diff.blocks[load.anchor]).toMatchObject({ change: 'mod', own: true, marks: { rows: { changed: { 1: { value: { markdown: expect.any(Array) } } } } } }) expect(diff.blocks[returned.anchor]).toMatchObject({ change: 'mod', own: true, marks: { rows: { changed: { 0: { value: { markdown: expect.any(Array) } } } } } }) expect(diff.blocks[returned.anchor].marks).not.toHaveProperty('translation') }) })