- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」 - 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它 - 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面 - TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释 - 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
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<SourcedBlocks> {
|
|
const { blocks } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
|
return { blocks, source }
|
|
}
|
|
|
|
/** 手写的一块:只填比对用得到的几样。 */
|
|
function view(anchor: string, kind: string, code: string, extra: Partial<BlockView> = {}): 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')
|
|
})
|
|
})
|