mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
fix(docs): reject translated heading loss
This commit is contained in:
@@ -26,8 +26,9 @@ var docsProtocolTokens = []string{
|
||||
}
|
||||
|
||||
type docChunkStructure struct {
|
||||
fenceCount int
|
||||
tagCounts map[string]int
|
||||
fenceCount int
|
||||
tagCounts map[string]int
|
||||
headingLevels []int
|
||||
}
|
||||
|
||||
type docChunkSplitPlan struct {
|
||||
@@ -204,6 +205,9 @@ func validateDocChunkTranslation(source, translated string) error {
|
||||
if sourceStructure.fenceCount != translatedStructure.fenceCount {
|
||||
return fmt.Errorf("code fence mismatch: source=%d translated=%d", sourceStructure.fenceCount, translatedStructure.fenceCount)
|
||||
}
|
||||
if !slices.Equal(sourceStructure.headingLevels, translatedStructure.headingLevels) {
|
||||
return fmt.Errorf("heading structure mismatch: source=%v translated=%v", sourceStructure.headingLevels, translatedStructure.headingLevels)
|
||||
}
|
||||
if !slices.Equal(sortedKeys(sourceStructure.tagCounts), sortedKeys(translatedStructure.tagCounts)) {
|
||||
return fmt.Errorf("component tag set mismatch")
|
||||
}
|
||||
@@ -335,8 +339,9 @@ func summarizeDocChunkStructure(text string) docChunkStructure {
|
||||
}
|
||||
}
|
||||
return docChunkStructure{
|
||||
fenceCount: counts["__fence_toggle__"],
|
||||
tagCounts: countsWithoutFence(counts),
|
||||
fenceCount: counts["__fence_toggle__"],
|
||||
tagCounts: countsWithoutFence(counts),
|
||||
headingLevels: extractMarkdownHeadingLevels(text),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -475,6 +475,108 @@ func TestValidateDocChunkTranslationRejectsInventedI18NPlaceholder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsHeadingLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "## Detailed behavior and rationale\n\nExplanation.\n"
|
||||
translated := "详细说明。\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected heading loss to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
|
||||
t.Fatalf("expected heading structure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationAcceptsTranslatedHeadingText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "## Detailed behavior and rationale\n\nExplanation.\n"
|
||||
translated := "## 详细行为与设计理由\n\n说明。\n"
|
||||
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
t.Fatalf("expected translated heading text with the same level to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsSetextHeadingLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Detailed behavior and rationale\n---------------------------------\n\nExplanation.\n"
|
||||
translated := "详细说明。\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected Setext heading loss to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
|
||||
t.Fatalf("expected Setext heading structure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationAcceptsTranslatedSetextHeading(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "Detailed behavior and rationale\n---------------------------------\n\nExplanation.\n"
|
||||
translated := "详细行为与设计理由\n------------------\n\n说明。\n"
|
||||
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
t.Fatalf("expected translated Setext heading with the same level to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationDoesNotTreatThematicBreakAsSetextHeading(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "- First item\n\n---\n\nParagraph.\n"
|
||||
translated := "- 第一项\n\n---\n\n段落。\n"
|
||||
|
||||
if err := validateDocChunkTranslation(source, translated); err != nil {
|
||||
t.Fatalf("expected thematic break to remain distinct from a Setext heading, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsNestedHeadingLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "> ## Warning\n>\n> Keep this setting enabled.\n\n- ### Step\n Run the command.\n"
|
||||
translated := "> 警告\n>\n> 保持此设置启用。\n\n- 步骤\n 运行命令。\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected nested heading loss to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2 3] translated=[]") {
|
||||
t.Fatalf("expected nested heading structure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsComponentNestedHeadingLoss(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := "<Note>\n## Important\n\nKeep this setting enabled.\n</Note>\n"
|
||||
translated := "<Note>\n重要\n\n保持此设置启用。\n</Note>\n"
|
||||
|
||||
err := validateDocChunkTranslation(source, translated)
|
||||
if err == nil {
|
||||
t.Fatal("expected component-nested heading loss to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
|
||||
t.Fatalf("expected component-nested heading structure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingExtractionIgnoresComponentExamplesInsideCodeFences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := "```mdx\n<Note>\n## Example only\n</Note>\n```\n"
|
||||
if levels := extractMarkdownHeadingLevels(text); len(levels) != 0 {
|
||||
t.Fatalf("expected no headings from fenced component example, got %v", levels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocChunkTranslationRejectsTranscriptArtifact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -94,6 +94,38 @@ func extractSegments(body, relPath string) ([]Segment, error) {
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func extractMarkdownHeadingLevels(body string) []int {
|
||||
source := []byte(stripDocComponentTagsForHeadingParse(body))
|
||||
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(source))
|
||||
levels := []int{}
|
||||
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
heading, ok := node.(*ast.Heading)
|
||||
if ok {
|
||||
levels = append(levels, heading.Level)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return levels
|
||||
}
|
||||
|
||||
func stripDocComponentTagsForHeadingParse(body string) string {
|
||||
lines := strings.Split(body, "\n")
|
||||
fenceDelimiter := ""
|
||||
for index, line := range lines {
|
||||
wasInFence := fenceDelimiter != ""
|
||||
var toggled bool
|
||||
fenceDelimiter, toggled = updateFenceDelimiter(fenceDelimiter, line)
|
||||
if wasInFence || toggled || fenceDelimiter != "" {
|
||||
continue
|
||||
}
|
||||
lines[index] = docsComponentTagRE.ReplaceAllString(line, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func blockParent(n ast.Node) ast.Node {
|
||||
for node := n.Parent(); node != nil; node = node.Parent() {
|
||||
if isTranslatableBlock(node) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const (
|
||||
workflowVersion = 16
|
||||
promptVersion = 7
|
||||
promptVersion = 8
|
||||
docsI18nEngineName = "codex"
|
||||
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
|
||||
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
func TestCacheNamespaceIncludesPromptVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if want := "prompt=7"; !strings.Contains(cacheNamespace(), want) {
|
||||
if want := "prompt=8"; !strings.Contains(cacheNamespace(), want) {
|
||||
t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user