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
969 lines
30 KiB
Go
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 }
|