- 模块名重命名 yunshu -> hub.gaomia.site/titor/YunShu - Go 版本升级 1.21 -> 1.25 - src/ 目录删除,所有文件移至根目录 - 新增 pkg/mdprint/: Markdown AST 解析+ANSI 渲染 - 新增 pkg/style/: 终端颜色样式(8色 ANSI + 24位真彩色) - 新增 pkg/termui/: 终端输入组件(交互式输入/密码/确认) - 更新文档:AGENTS.md、architecture.md、changelog.md、taolun.md - gitignore 通配符修复 yunshu.exe -> yunshu.exe*
309 lines
7.3 KiB
Go
309 lines
7.3 KiB
Go
package mdprint
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func stripANSI(s string) string {
|
||
var b strings.Builder
|
||
i := 0
|
||
for i < len(s) {
|
||
if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' {
|
||
j := i + 2
|
||
for j < len(s) && s[j] != 'm' {
|
||
j++
|
||
}
|
||
if j < len(s) {
|
||
i = j + 1
|
||
continue
|
||
}
|
||
}
|
||
b.WriteByte(s[i])
|
||
i++
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func TestParseInline_Text(t *testing.T) {
|
||
nodes := parseInline("hello world")
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||
}
|
||
_, ok := nodes[0].(Text)
|
||
if !ok {
|
||
t.Fatalf("expected Text, got %T", nodes[0])
|
||
}
|
||
}
|
||
|
||
func TestParseInline_Bold(t *testing.T) {
|
||
nodes := parseInline("**bold**")
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||
}
|
||
b, ok := nodes[0].(Bold)
|
||
if !ok {
|
||
t.Fatalf("expected Bold, got %T", nodes[0])
|
||
}
|
||
if len(b.Content) != 1 {
|
||
t.Fatalf("expected 1 child, got %d", len(b.Content))
|
||
}
|
||
txt, ok := b.Content[0].(Text)
|
||
if !ok {
|
||
t.Fatalf("expected Text in Bold, got %T", b.Content[0])
|
||
}
|
||
if txt.Text != "bold" {
|
||
t.Fatalf("expected 'bold', got '%s'", txt.Text)
|
||
}
|
||
}
|
||
|
||
func TestParseInline_Italic(t *testing.T) {
|
||
nodes := parseInline("*italic*")
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||
}
|
||
_, ok := nodes[0].(Italic)
|
||
if !ok {
|
||
t.Fatalf("expected Italic, got %T", nodes[0])
|
||
}
|
||
}
|
||
|
||
func TestParseInline_Code(t *testing.T) {
|
||
nodes := parseInline("`code`")
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||
}
|
||
c, ok := nodes[0].(Code)
|
||
if !ok {
|
||
t.Fatalf("expected Code, got %T", nodes[0])
|
||
}
|
||
if c.Text != "code" {
|
||
t.Fatalf("expected 'code', got '%s'", c.Text)
|
||
}
|
||
}
|
||
|
||
func TestParseInline_Link(t *testing.T) {
|
||
nodes := parseInline("[text](url)")
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||
}
|
||
link, ok := nodes[0].(Link)
|
||
if !ok {
|
||
t.Fatalf("expected Link, got %T", nodes[0])
|
||
}
|
||
if link.URL != "url" {
|
||
t.Fatalf("expected URL 'url', got '%s'", link.URL)
|
||
}
|
||
if len(link.Content) != 1 {
|
||
t.Fatalf("expected 1 child in link, got %d", len(link.Content))
|
||
}
|
||
txt, ok := link.Content[0].(Text)
|
||
if !ok {
|
||
t.Fatalf("expected Text in Link, got %T", link.Content[0])
|
||
}
|
||
if txt.Text != "text" {
|
||
t.Fatalf("expected 'text', got '%s'", txt.Text)
|
||
}
|
||
}
|
||
|
||
func TestParseInline_Mixed(t *testing.T) {
|
||
nodes := parseInline("a **b** c")
|
||
if len(nodes) != 3 {
|
||
t.Fatalf("expected 3 nodes, got %d: %#v", len(nodes), nodes)
|
||
}
|
||
t1, ok := nodes[0].(Text)
|
||
if !ok || t1.Text != "a " {
|
||
t.Fatalf("expected 'a ' Text, got %#v", nodes[0])
|
||
}
|
||
_, ok = nodes[1].(Bold)
|
||
if !ok {
|
||
t.Fatalf("expected Bold at index 1, got %T", nodes[1])
|
||
}
|
||
t2, ok := nodes[2].(Text)
|
||
if !ok || t2.Text != " c" {
|
||
t.Fatalf("expected ' c' Text, got %#v", nodes[2])
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_Heading(t *testing.T) {
|
||
blocks := parseBlocks("# Title")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
h, ok := blocks[0].(Heading)
|
||
if !ok {
|
||
t.Fatalf("expected Heading, got %T", blocks[0])
|
||
}
|
||
if h.Level != 1 {
|
||
t.Fatalf("expected level 1, got %d", h.Level)
|
||
}
|
||
if stripANSI(renderNode(h)) != "\n▪ Title\n" {
|
||
t.Fatalf("got '%s'", stripANSI(renderNode(h)))
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_CodeFence(t *testing.T) {
|
||
blocks := parseBlocks("```go\npackage main\n```")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
cb, ok := blocks[0].(CodeBlock)
|
||
if !ok {
|
||
t.Fatalf("expected CodeBlock, got %T", blocks[0])
|
||
}
|
||
if cb.Lang != "go" {
|
||
t.Fatalf("expected lang 'go', got '%s'", cb.Lang)
|
||
}
|
||
body := stripANSI(renderNode(cb))
|
||
if !strings.Contains(body, "package main") {
|
||
t.Fatalf("expected 'package main' in body")
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_Blockquote(t *testing.T) {
|
||
blocks := parseBlocks("> hello\n> world")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
_, ok := blocks[0].(Blockquote)
|
||
if !ok {
|
||
t.Fatalf("expected Blockquote, got %T", blocks[0])
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_UnorderedList(t *testing.T) {
|
||
blocks := parseBlocks("- a\n- b\n- c")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
l, ok := blocks[0].(List)
|
||
if !ok {
|
||
t.Fatalf("expected List, got %T", blocks[0])
|
||
}
|
||
if len(l.Items) != 3 {
|
||
t.Fatalf("expected 3 items, got %d", len(l.Items))
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_OrderedList(t *testing.T) {
|
||
blocks := parseBlocks("1. a\n2. b")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
l, ok := blocks[0].(List)
|
||
if !ok {
|
||
t.Fatalf("expected List, got %T", blocks[0])
|
||
}
|
||
if !l.Ordered {
|
||
t.Fatalf("expected ordered list")
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_Table(t *testing.T) {
|
||
blocks := parseBlocks("| H1 | H2 |\n|---|----|\n| A | B |")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
tbl, ok := blocks[0].(Table)
|
||
if !ok {
|
||
t.Fatalf("expected Table, got %T", blocks[0])
|
||
}
|
||
if len(tbl.Headers) != 2 {
|
||
t.Fatalf("expected 2 headers, got %d", len(tbl.Headers))
|
||
}
|
||
if len(tbl.Rows) != 1 {
|
||
t.Fatalf("expected 1 row, got %d", len(tbl.Rows))
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_ThematicBreak(t *testing.T) {
|
||
blocks := parseBlocks("---")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
_, ok := blocks[0].(ThematicBreak)
|
||
if !ok {
|
||
t.Fatalf("expected ThematicBreak, got %T", blocks[0])
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_Paragraph(t *testing.T) {
|
||
blocks := parseBlocks("hello\nworld")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("expected 1 block, got %d", len(blocks))
|
||
}
|
||
_, ok := blocks[0].(Paragraph)
|
||
if !ok {
|
||
t.Fatalf("expected Paragraph, got %T", blocks[0])
|
||
}
|
||
}
|
||
|
||
func TestParseBlocks_MultiBlock(t *testing.T) {
|
||
content := "# Title\n\nhello **world**\n\n> quote"
|
||
blocks := parseBlocks(content)
|
||
if len(blocks) != 3 {
|
||
t.Fatalf("expected 3 blocks, got %d", len(blocks))
|
||
}
|
||
}
|
||
|
||
func TestRender_HeadingLevels(t *testing.T) {
|
||
for level := 1; level <= 6; level++ {
|
||
prefix := strings.Repeat("#", level)
|
||
blocks := parseBlocks(prefix + " test")
|
||
if len(blocks) != 1 {
|
||
t.Fatalf("heading level %d: expected 1 block", level)
|
||
}
|
||
h, ok := blocks[0].(Heading)
|
||
if !ok {
|
||
t.Fatalf("heading level %d: expected Heading", level)
|
||
}
|
||
if h.Level != level {
|
||
t.Fatalf("heading level %d: expected level=%d", level, level)
|
||
}
|
||
result := stripANSI(renderNode(h))
|
||
var expected string
|
||
switch level {
|
||
case 1:
|
||
expected = "\n▪ test\n"
|
||
case 2, 3:
|
||
expected = "\n▪ test"
|
||
case 4, 5:
|
||
expected = "\n▫ test"
|
||
case 6:
|
||
expected = "\ntest"
|
||
}
|
||
if result != expected {
|
||
t.Fatalf("heading level %d: got '%q', expected '%q'", level, result, expected)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRender_BoldItalic(t *testing.T) {
|
||
nodes := parseInline("**bold** and *italic*")
|
||
r := stripANSI(renderInline(nodes))
|
||
if r != "bold and italic" {
|
||
t.Fatalf("got '%s'", r)
|
||
}
|
||
}
|
||
|
||
func TestRender_Link(t *testing.T) {
|
||
nodes := parseInline("[click](https://example.com)")
|
||
r := stripANSI(renderInline(nodes))
|
||
if !strings.Contains(r, "click") || !strings.Contains(r, "https://example.com") {
|
||
t.Fatalf("got '%s'", r)
|
||
}
|
||
}
|
||
|
||
func TestRender_FullDocument(t *testing.T) {
|
||
content := "# 天气报告\n\n今天**晴**,温度 *适中*。\n\n> 温馨提示:出门带伞\n\n- 温度:25°C\n- 湿度:40%\n\n代码:`fmt.Println(\"hello\")`\n\n| 项目 | 值 |\n|------|----|\n| AQI | 42 |\n"
|
||
|
||
blocks := parseBlocks(content)
|
||
if len(blocks) == 0 {
|
||
t.Fatal("expected blocks")
|
||
}
|
||
_, ok := blocks[0].(Heading)
|
||
if !ok {
|
||
t.Fatal("first block should be Heading")
|
||
}
|
||
}
|