Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

969 lines
30 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
func TestFormatTimestamp(t *testing.T) {
convey.Convey("formatTimestamp", t, func() {
convey.Convey("empty string returns empty", func() {
result := formatTimestamp("")
convey.So(result, convey.ShouldEqual, "")
})
convey.Convey("valid timestamp formats correctly", func() {
result := formatTimestamp("1735689600000")
// 不检查具体的时分秒,因为时区不同结果会不同
convey.So(result, convey.ShouldStartWith, "2025-01-01")
})
convey.Convey("invalid timestamp returns original", func() {
result := formatTimestamp("not-a-number")
convey.So(result, convey.ShouldEqual, "not-a-number")
})
})
}
func TestToRespMethods(t *testing.T) {
convey.Convey("ToResp methods handle nil", t, func() {
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
convey.So((*ProgressV1)(nil).ToResp(), convey.ShouldBeNil)
})
convey.Convey("ToResp methods work with valid objects", t, func() {
convey.Convey("Cycle", func() {
cycle := &Cycle{
ID: "cycle-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
TenantCycleID: "tenant-cycle-id",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
StartTime: "1735689600000",
EndTime: "1751318400000",
CycleStatus: CycleStatusNormal.Ptr(),
Score: float64Ptr(0.75),
}
resp := cycle.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
// Verify removed fields are not present in RespCycle
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
})
convey.Convey("Objective", func() {
obj := &Objective{
ID: "obj-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
CycleID: "cycle-id",
Position: int32Ptr(1),
Score: float64Ptr(0.8),
Weight: float64Ptr(1.0),
Deadline: strPtr("1751318400000"),
Content: &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Test objective"),
},
},
},
},
},
},
},
}
resp := obj.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
})
convey.Convey("KeyResult", func() {
kr := &KeyResult{
ID: "kr-id",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
ObjectiveID: "obj-id",
Position: int32Ptr(1),
Content: &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Test KR"),
},
},
},
},
},
},
},
Score: float64Ptr(0.9),
Weight: float64Ptr(0.5),
Deadline: strPtr("1751318400000"),
}
resp := kr.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
})
convey.Convey("ProgressV1", func() {
record := &ProgressV1{
ID: "progress-id",
ModifyTime: "1735776000000",
Content: &ContentBlockV1{
Blocks: []ContentBlockElementV1{
{
Type: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraphV1{
Elements: []ContentParagraphElementV1{
{
Type: ParagraphElementTypeV1TextRun.Ptr(),
TextRun: &ContentTextRunV1{
Text: strPtr("Hello progress"),
},
},
},
},
},
},
},
ProgressRate: &ProgressRateV1{
Percent: float64Ptr(75.0),
Status: int32Ptr(0),
},
}
resp := record.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "progress-id")
convey.So(resp.ModifyTime, convey.ShouldStartWith, "2025-01-02")
convey.So(resp.Content, convey.ShouldNotBeNil)
convey.So(*resp.Content, convey.ShouldContainSubstring, "Hello progress")
convey.So(resp.ProgressRate, convey.ShouldNotBeNil)
convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 75.0)
})
convey.Convey("ProgressV1 with empty content", func() {
record := &ProgressV1{
ID: "progress-id-2",
ModifyTime: "1735776000000",
}
resp := record.ToResp()
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.Content, convey.ShouldBeNil)
convey.So(resp.ProgressRate, convey.ShouldBeNil)
})
})
}
func TestContentBlockV1V2RoundTrip(t *testing.T) {
convey.Convey("ContentBlock V1↔V2 round-trip", t, func() {
original := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Style: &ContentParagraphStyle{
List: &ContentList{
ListType: listTypePtr(ListTypeBullet),
IndentLevel: int32Ptr(1),
Number: int32Ptr(2),
},
},
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
Style: &ContentTextStyle{
Bold: boolPtr(true),
StrikeThrough: boolPtr(false),
},
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
URL: strPtr("https://example.com"),
Title: strPtr("Example"),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{FileToken: strPtr("ftoken1"), Width: float64Ptr(100), Height: float64Ptr(200)},
},
},
},
},
}
// V2 -> V1
v1 := original.ToV1()
convey.So(v1, convey.ShouldNotBeNil)
convey.So(len(v1.Blocks), convey.ShouldEqual, 2)
// V1 -> V2
v2 := v1.ToV2()
convey.So(v2, convey.ShouldNotBeNil)
convey.So(len(v2.Blocks), convey.ShouldEqual, 2)
// Verify first block (paragraph)
convey.So(*v2.Blocks[0].BlockElementType, convey.ShouldEqual, BlockElementTypeParagraph)
convey.So(v2.Blocks[0].Paragraph, convey.ShouldNotBeNil)
convey.So(len(v2.Blocks[0].Paragraph.Elements), convey.ShouldEqual, 3)
// TextRun
textRunElem := v2.Blocks[0].Paragraph.Elements[0]
convey.So(*textRunElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeTextRun)
convey.So(textRunElem.TextRun, convey.ShouldNotBeNil)
convey.So(*textRunElem.TextRun.Text, convey.ShouldEqual, "Hello world")
convey.So(textRunElem.TextRun.Style, convey.ShouldNotBeNil)
convey.So(*textRunElem.TextRun.Style.Bold, convey.ShouldBeTrue)
// DocsLink
docsLinkElem := v2.Blocks[0].Paragraph.Elements[1]
convey.So(*docsLinkElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeDocsLink)
convey.So(docsLinkElem.DocsLink, convey.ShouldNotBeNil)
convey.So(*docsLinkElem.DocsLink.URL, convey.ShouldEqual, "https://example.com")
// Mention
mentionElem := v2.Blocks[0].Paragraph.Elements[2]
convey.So(*mentionElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeMention)
convey.So(mentionElem.Mention, convey.ShouldNotBeNil)
convey.So(*mentionElem.Mention.UserID, convey.ShouldEqual, "ou_123")
// Verify second block (gallery)
convey.So(*v2.Blocks[1].BlockElementType, convey.ShouldEqual, BlockElementTypeGallery)
convey.So(v2.Blocks[1].Gallery, convey.ShouldNotBeNil)
convey.So(len(v2.Blocks[1].Gallery.Images), convey.ShouldEqual, 1)
// Verify list style round-trip
convey.So(v2.Blocks[0].Paragraph.Style, convey.ShouldNotBeNil)
convey.So(v2.Blocks[0].Paragraph.Style.List, convey.ShouldNotBeNil)
convey.So(*v2.Blocks[0].Paragraph.Style.List.ListType, convey.ShouldEqual, ListTypeBullet)
convey.So(*v2.Blocks[0].Paragraph.Style.List.IndentLevel, convey.ShouldEqual, 1)
})
convey.Convey("nil ContentBlock round-trip", t, func() {
convey.So((*ContentBlock)(nil).ToV1(), convey.ShouldBeNil)
convey.So((*ContentBlockV1)(nil).ToV2(), convey.ShouldBeNil)
})
}
func TestContentBlockV1JSON(t *testing.T) {
convey.Convey("ContentBlockV1 JSON serialization", t, func() {
v1 := &ContentBlockV1{
Blocks: []ContentBlockElementV1{
{
Type: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraphV1{
Elements: []ContentParagraphElementV1{
{
Type: ParagraphElementTypeV1TextRun.Ptr(),
TextRun: &ContentTextRunV1{Text: strPtr("test")},
},
},
},
},
},
}
data, err := json.Marshal(v1)
convey.So(err, convey.ShouldBeNil)
convey.So(string(data), convey.ShouldContainSubstring, "paragraph")
convey.So(string(data), convey.ShouldContainSubstring, "textRun")
convey.So(string(data), convey.ShouldContainSubstring, "test")
})
}
func TestProgressRecordToResp_ContentBlockV1Conversion(t *testing.T) {
convey.Convey("ProgressV1.ToResp converts V1 content to V2 JSON", t, func() {
record := &ProgressV1{
ID: "rec-123",
ModifyTime: "1735776000000",
Content: &ContentBlockV1{
Blocks: []ContentBlockElementV1{
{
Type: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraphV1{
Elements: []ContentParagraphElementV1{
{
Type: ParagraphElementTypeV1TextRun.Ptr(),
TextRun: &ContentTextRunV1{Text: strPtr("V1 content")},
},
{
Type: ParagraphElementTypeV1Mention.Ptr(),
Person: &ContentPersonV1{OpenID: strPtr("ou_mention")},
},
},
},
},
},
},
}
resp := record.ToResp()
convey.So(resp.Content, convey.ShouldNotBeNil)
// Content should be V2 format JSON string
convey.So(*resp.Content, convey.ShouldContainSubstring, "block_element_type")
convey.So(*resp.Content, convey.ShouldContainSubstring, "V1 content")
convey.So(*resp.Content, convey.ShouldContainSubstring, "user_id")
})
}
func TestParseProgressRecord(t *testing.T) {
convey.Convey("parseProgressRecord", t, func() {
convey.Convey("valid data", func() {
data := map[string]any{
"progress_id": "123",
"modify_time": "1735776000000",
"content": map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"paragraph": map[string]any{
"elements": []any{
map[string]any{
"type": "textRun",
"textRun": map[string]any{"text": "test"},
},
},
},
},
},
},
}
record, err := parseProgressRecord(data)
convey.So(err, convey.ShouldBeNil)
convey.So(record.ID, convey.ShouldEqual, "123")
convey.So(record.Content, convey.ShouldNotBeNil)
})
convey.Convey("empty data", func() {
data := map[string]any{}
record, err := parseProgressRecord(data)
convey.So(err, convey.ShouldBeNil)
convey.So(record.ID, convey.ShouldEqual, "")
})
})
}
func TestParseCreateProgressRecordParams_BrandAwareSourceURL(t *testing.T) {
convey.Convey("parseCreateProgressRecordParams brand-aware defaults", t, func() {
// This test directly tests the brand-aware default logic by constructing
// a minimal ContentBlock JSON and checking the resolved sourceURL.
convey.Convey("feishu brand defaults to feishu.cn", func() {
url := core.ResolveOpenBaseURL(core.BrandFeishu) + "/app"
convey.So(url, convey.ShouldEqual, "https://open.feishu.cn/app")
})
convey.Convey("lark brand defaults to larksuite.com", func() {
url := core.ResolveOpenBaseURL(core.BrandLark) + "/app"
convey.So(url, convey.ShouldEqual, "https://open.larksuite.com/app")
})
})
}
func TestProgressStatus(t *testing.T) {
convey.Convey("ProgressStatus parsing and string conversion", t, func() {
convey.Convey("ParseProgressStatus accepts string names", func() {
s, ok := ParseProgressStatus("normal")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusNormal)
s, ok = ParseProgressStatus("overdue")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusOverdue)
s, ok = ParseProgressStatus("done")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusDone)
})
convey.Convey("ParseProgressStatus accepts numeric strings", func() {
s, ok := ParseProgressStatus("0")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusNormal)
s, ok = ParseProgressStatus("1")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusOverdue)
s, ok = ParseProgressStatus("2")
convey.So(ok, convey.ShouldBeTrue)
convey.So(s, convey.ShouldEqual, ProgressStatusDone)
})
convey.Convey("ParseProgressStatus rejects invalid values", func() {
_, ok := ParseProgressStatus("invalid")
convey.So(ok, convey.ShouldBeFalse)
})
convey.Convey("String returns human-readable names", func() {
convey.So(ProgressStatusNormal.String(), convey.ShouldEqual, "normal")
convey.So(ProgressStatusOverdue.String(), convey.ShouldEqual, "overdue")
convey.So(ProgressStatusDone.String(), convey.ShouldEqual, "done")
})
})
}
func TestProgressV1ToResp_StatusConversion(t *testing.T) {
convey.Convey("ProgressV1.ToResp converts Status int to string", t, func() {
convey.Convey("status=0 → normal", func() {
record := &ProgressV1{
ID: "rec-1",
ModifyTime: "1735776000000",
ProgressRate: &ProgressRateV1{
Percent: float64Ptr(50.0),
Status: int32Ptr(0),
},
}
resp := record.ToResp()
convey.So(resp.ProgressRate, convey.ShouldNotBeNil)
convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "normal")
convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 50.0)
})
convey.Convey("status=1 → overdue", func() {
record := &ProgressV1{
ID: "rec-2",
ModifyTime: "1735776000000",
ProgressRate: &ProgressRateV1{
Percent: float64Ptr(30.0),
Status: int32Ptr(1),
},
}
resp := record.ToResp()
convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "overdue")
})
convey.Convey("status=2 → done", func() {
record := &ProgressV1{
ID: "rec-3",
ModifyTime: "1735776000000",
ProgressRate: &ProgressRateV1{
Percent: float64Ptr(100.0),
Status: int32Ptr(2),
},
}
resp := record.ToResp()
convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "done")
})
convey.Convey("nil ProgressRate", func() {
record := &ProgressV1{
ID: "rec-4",
ModifyTime: "1735776000000",
}
resp := record.ToResp()
convey.So(resp.ProgressRate, convey.ShouldBeNil)
})
convey.Convey("nil Status in ProgressRate", func() {
record := &ProgressV1{
ID: "rec-5",
ModifyTime: "1735776000000",
ProgressRate: &ProgressRateV1{
Percent: float64Ptr(75.0),
},
}
resp := record.ToResp()
convey.So(resp.ProgressRate, convey.ShouldNotBeNil)
convey.So(resp.ProgressRate.Status, convey.ShouldBeNil)
convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 75.0)
})
})
}
// strPtr returns a pointer to the given string value.
func strPtr(v string) *string { return &v }
// float64Ptr returns a pointer to the given float64 value.
func float64Ptr(v float64) *float64 { return &v }
// boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v }
// ========== SemiPlainContent Conversion Tests ==========
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Hello world" {
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
}
if len(sp.Mention) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
}
if len(sp.Docs) != 0 {
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
}
if len(sp.Images) != 0 {
t.Fatalf("expected 0 images, got %d", len(sp.Images))
}
}
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello "),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr(", how are you?"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
// Text includes @{userID} placeholder to preserve positional context
if sp.Text != "Hello @{ou_123} , how are you?" {
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
}
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
}
}
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Check out this doc: "),
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
Title: strPtr("Design Doc"),
URL: strPtr("https://example.feishu.cn/docx/xxx"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{
Src: strPtr("https://example.com/img1.png"),
},
{
Src: strPtr("https://example.com/img2.png"),
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Check out this doc: " {
t.Fatalf("unexpected text: '%s'", sp.Text)
}
if len(sp.Docs) != 1 {
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
}
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
}
if len(sp.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(sp.Images))
}
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
t.Fatalf("unexpected images: %v", sp.Images)
}
}
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
t.Parallel()
var cb *ContentBlock
sp := cb.ToSemiPlain()
if sp != nil {
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
}
}
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Hello world",
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatal("expected paragraph block")
}
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
}
elem := block.Paragraph.Elements[0]
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected textRun element")
}
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
t.Fatalf("unexpected text: %v", elem.TextRun)
}
}
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Please review",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
t.Fatal("unexpected first element")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: " ",
Mention: []string{"ou_123"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Empty text should be skipped, only mention remains
if len(elems) != 1 {
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected mention element")
}
}
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Test",
Mention: []string{"ou_123"},
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
Images: []string{"https://img.png"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Docs and images are ignored in input conversion
if len(elems) != 2 {
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
}
}
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
t.Parallel()
// Simulate round-trip: output format has @{userID} in text,
// input conversion should strip them to avoid duplicate mentions
sp := &SemiPlainContent{
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
Mention: []string{"ou_zhangsan", "ou_lisi"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have 3 elements: 1 text (stripped) + 2 mentions
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
// Text should have placeholders stripped
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
// Note: space before comma is preserved from the placeholder's trailing space
expectedText := "任务一 ,任务二"
if *elems[0].TextRun.Text != expectedText {
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
}
// Mentions should be preserved as separate elements
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
t.Parallel()
// Text that is only placeholders should result in no text element
sp := &SemiPlainContent{
Text: " @{ou_123} @{ou_456} ",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have only 2 mention elements, no text element
if len(elems) != 2 {
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected first element to be mention")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected second element to be mention")
}
}
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
t.Parallel()
var sp *SemiPlainContent
cb := sp.ToContentBlock()
if cb != nil {
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
}
}
func TestBuildContentBlock_Conversion(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements, got %d", len(elems))
}
if *elems[0].TextRun.Text != "Test text" {
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
}
if *elems[1].Mention.UserID != "ou_123" {
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
}
if *elems[2].Mention.UserID != "ou_456" {
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
}
}
func TestToSimpleMethods(t *testing.T) {
t.Parallel()
// Test Objective.ToSimple()
text := "Objective text"
obj := &Objective{
ID: "obj-1",
Content: BuildContentBlock(text, []string{"ou_123"}),
Notes: BuildContentBlock("Note text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
CycleID: "cycle-1",
Score: float64Ptr(0.7),
Weight: float64Ptr(0.5),
Deadline: strPtr("1735776000000"),
}
simpleObj := obj.ToSimple()
if simpleObj == nil {
t.Fatal("expected non-nil RespObjectiveSimple")
}
if simpleObj.ID != "obj-1" {
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
}
// Text includes @{userID} placeholder for positional context
expectedContentText := "Objective text @{ou_123} "
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
}
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
}
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
t.Fatalf("unexpected score: %v", simpleObj.Score)
}
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
}
// Test KeyResult.ToSimple()
kr := &KeyResult{
ID: "kr-1",
ObjectiveID: "obj-1",
Content: BuildContentBlock("KR text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
Score: float64Ptr(0.5),
}
simpleKR := kr.ToSimple()
if simpleKR == nil {
t.Fatal("expected non-nil RespKeyResultSimple")
}
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
}
// Test ProgressV1.ToSimple()
progress := &ProgressV1{
ID: "prog-1",
ModifyTime: "1735776000000",
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
}
simpleProgress := progress.ToSimple()
if simpleProgress == nil {
t.Fatal("expected non-nil RespProgressSimple")
}
// Text includes @{userID} placeholder for positional context
expectedProgressText := "Progress text @{ou_mention} "
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
}
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
}
// Test Progress.ToSimple() (V2 progress record)
progressV2 := &Progress{
ID: "prog-v2-1",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
ProgressRate: &ProgressRate{
ProgressPercent: float64Ptr(80.0),
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
},
}
simpleProgressV2 := progressV2.ToSimple()
if simpleProgressV2 == nil {
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
}
if simpleProgressV2.ID != "prog-v2-1" {
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
}
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
t.Fatal("expected non-empty CreateTime for Progress V2")
}
expectedV2Text := "V2 progress text @{ou_v2_mention} "
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
}
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
}
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
}
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
}
}
// listTypePtr returns a pointer to the given ListType value.
func listTypePtr(v ListType) *ListType { return &v }