diff --git a/CI/live_tests/provider_submission_test.go b/CI/live_tests/provider_submission_test.go index 8d8ab29..6b2cdaa 100644 --- a/CI/live_tests/provider_submission_test.go +++ b/CI/live_tests/provider_submission_test.go @@ -25,26 +25,16 @@ func TestLiveProviderParse(t *testing.T) { t.Skip("set SURVEYCORE_LIVE_PARSE=1 to enable live parser checks") } for _, survey := range []struct { - provider string - url string - parseError string + provider string + url string }{ - {model.ProviderWJX, "https://v.wjx.cn/vm/ei3sVrE.aspx", ""}, - // This published fixture includes rating types the parser explicitly rejects. - {model.ProviderQQ, "https://wj.qq.com/s2/26070328/fa89/", "腾讯问卷当前版本暂不支持量表、矩阵量表题"}, - {model.ProviderCredamo, "https://www.credamo.com/s/A73QR3ano", ""}, + {model.ProviderWJX, "https://v.wjx.cn/vm/ei3sVrE.aspx"}, + {model.ProviderQQ, "https://wj.qq.com/s2/26070328/fa89/"}, + {model.ProviderCredamo, "https://www.credamo.com/s/A73QR3ano"}, } { t.Run(survey.provider, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() - if survey.parseError != "" { - _, err := surveycore.New().Parse(ctx, survey.url) - if !errors.Is(err, surveycore.ErrParseFailed) || !strings.Contains(err.Error(), survey.parseError) { - t.Fatalf("expected the fixture's unsupported-type error, got %v", err) - } - t.Log("live questions fetched; unsupported rating types correctly reported") - return - } config := defaultConfigWithRetry(t, ctx, surveycore.New(), survey.url) if config.SurveySource.Provider != survey.provider || config.SurveyDefinition.Provider != survey.provider { t.Fatalf("provider mismatch: source=%q definition=%q", config.SurveySource.Provider, config.SurveyDefinition.Provider) diff --git a/README.md b/README.md index 8cff99c..4ae9a58 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ SurveyCore 是 SurveyController 使用的问卷解析、配置、提交、代理 - [x] Credamo 见数 - [ ] ...(欢迎贡献!) +腾讯问卷支持单选、多选、下拉、文本、矩阵单选,以及 NPS、星级评分和矩阵评分。评分按平台分值提交,支持 0 分。 + ## 使用方法 ### 环境要求 diff --git a/docs/sdk/schemas.md b/docs/sdk/schemas.md index b3fdc4c..791d8f6 100644 --- a/docs/sdk/schemas.md +++ b/docs/sdk/schemas.md @@ -133,7 +133,7 @@ outline: deep Python `dataclasses.asdict(SurveyDefinition)` 可用于 HTTP 单次提交的 `survey`,或反序列化为 Go 的 `model.SurveyDefinition`。Go 额外保留每题的 `provider`;可选空集合可能省略。问卷星多级下拉按父子关系展开选项,显示文字与提交值分别保存;空的隐藏填空占位不会出现在题目列表中,提交时仍保留空位置。外部数据源及超过六级的层级数据会返回解析错误。 -腾讯问卷未解析出明确逻辑时保留 `logic_parse_status: "unknown"`。腾讯量表、矩阵量表等已有不支持范围保持原有解析错误。 +腾讯问卷未解析出明确逻辑时保留 `logic_parse_status: "unknown"`。支持 NPS(`nps`)、星级评分(`star`)和矩阵评分(`matrix_star`)。分数由 `star_begin_num` 与 `star_num` 生成,省略起点时 NPS 从 0 开始、星级及矩阵评分从 1 开始。默认策略中前两种使用 `score`,矩阵评分使用 `matrix`;提交时保留真实分值,0 分有效。空的显示条件及 `hidden: false` 不会阻止提交。 问卷星显示条件保留 `raw_relation`,并将 `题号,选项号` 转为从 0 开始的 `condition_option_indices`,同时为来源题目填充 `controls_display_targets`。空条件或 `-1` 不会生成显示约束。 diff --git a/pkg/surveycore/internal/providers/tencent/logic.go b/pkg/surveycore/internal/providers/tencent/logic.go index 52497bb..bec32da 100644 --- a/pkg/surveycore/internal/providers/tencent/logic.go +++ b/pkg/surveycore/internal/providers/tencent/logic.go @@ -96,7 +96,7 @@ func attachLogicMetadata(rawQuestions []map[string]any, questions []model.Questi if !ok { continue } - if raw["hidden"] != nil && raw["hidden"] != "" { + if hasHiddenLogic(raw["hidden"]) { questions[idx].HasDisplayCondition = true if questions[idx].LogicStatus != model.LogicParseStatusComplete { questions[idx].LogicStatus = model.LogicParseStatusUnknown @@ -106,6 +106,23 @@ func attachLogicMetadata(rawQuestions []map[string]any, questions []model.Questi return questions } +func hasHiddenLogic(value any) bool { + switch value := value.(type) { + case nil: + return false + case bool: + return value + case string: + return strings.TrimSpace(value) != "" + case []any: + return len(value) > 0 + case map[string]any: + return len(value) > 0 + default: + return boolValue(value) + } +} + func byIDNum(questions []model.QuestionMeta) map[string]int { result := map[string]int{} for _, question := range questions { diff --git a/pkg/surveycore/internal/providers/tencent/normalize.go b/pkg/surveycore/internal/providers/tencent/normalize.go index 6670c5b..593a347 100644 --- a/pkg/surveycore/internal/providers/tencent/normalize.go +++ b/pkg/surveycore/internal/providers/tencent/normalize.go @@ -25,18 +25,15 @@ var supportedProviderTypes = map[string]bool{ "select": true, "text": true, "textarea": true, + "nps": true, + "star": true, "matrix_radio": true, -} - -var blockedRuntimeProviderTypes = map[string]string{ - "nps": "量表", - "star": "量表", - "matrix_star": "矩阵量表", + "matrix_star": true, } func buildDefinition(rawQuestions []map[string]any, rawTitle string) (model.SurveyDefinition, error) { normalized := standardizeQuestions(rawQuestions) - if err := rejectBlockedQuestions(normalized); err != nil { + if err := rejectUnsupportedQuestions(normalized); err != nil { return model.SurveyDefinition{}, err } title := normalizeTitle(rawTitle) @@ -197,27 +194,18 @@ func normalizeTitle(raw string) string { return strings.TrimSpace(strings.Trim(title, " -_||")) } -func rejectBlockedQuestions(questions []model.QuestionMeta) error { - var blocked []string +func rejectUnsupportedQuestions(questions []model.QuestionMeta) error { var unsupported []string for _, question := range questions { if question.IsDescription { continue } - label := blockedRuntimeProviderTypes[question.ProviderType] - if label != "" { - blocked = append(blocked, fmt.Sprintf("第 %d 题:%s(%s)", question.Num, question.Title, label)) - continue - } if question.Unsupported { unsupported = append(unsupported, fmt.Sprintf("第 %d 题:%s(%s)", question.Num, question.Title, firstString(question.UnsupportedReason, question.ProviderType, question.TypeCode, "unknown"))) } } - if len(blocked) == 0 && len(unsupported) == 0 { + if len(unsupported) == 0 { return nil } - if len(blocked) > 0 { - return ParseError{Message: "腾讯问卷当前版本暂不支持量表、矩阵量表题,请改用 v3.2.2 旧版本:\n" + strings.Join(blocked, "\n")} - } return ParseError{Message: "腾讯问卷包含当前版本不支持的题型:\n" + strings.Join(unsupported, "\n")} } diff --git a/pkg/surveycore/internal/providers/tencent/normalize_options.go b/pkg/surveycore/internal/providers/tencent/normalize_options.go index 245b879..538bfa0 100644 --- a/pkg/surveycore/internal/providers/tencent/normalize_options.go +++ b/pkg/surveycore/internal/providers/tencent/normalize_options.go @@ -8,20 +8,9 @@ import ( func buildOptionTexts(question map[string]any, providerType string) []string { switch providerType { case "nps", "star": - start := intValue(question["star_begin_num"]) - count := maxInt(0, intValue(question["star_num"])) - values := make([]string, 0, count) - for i := 0; i < count; i++ { - values = append(values, fmt.Sprintf("%d", start+i)) - } - return values + return ratingOptionTexts(question, providerType) case "matrix_star": - count := maxInt(0, intValue(question["star_num"])) - values := make([]string, 0, count) - for i := 0; i < count; i++ { - values = append(values, fmt.Sprintf("%d", i+1)) - } - return values + return ratingOptionTexts(question, providerType) default: options := asMapList(question["options"]) values := make([]string, 0, len(options)) @@ -32,6 +21,22 @@ func buildOptionTexts(question map[string]any, providerType string) []string { } } +func ratingOptionTexts(question map[string]any, providerType string) []string { + start := 1 + if providerType == "nps" { + start = 0 + } + if question["star_begin_num"] != nil { + start = intValue(question["star_begin_num"]) + } + count := maxInt(0, intValue(question["star_num"])) + values := make([]string, 0, count) + for index := 0; index < count; index++ { + values = append(values, fmt.Sprintf("%d", start+index)) + } + return values +} + func buildRowTexts(question map[string]any) []string { rows := asMapList(question["sub_titles"]) values := make([]string, 0, len(rows)) @@ -123,9 +128,6 @@ func textInputs(providerType string) int { } func unsupportedReason(providerType string) string { - if label := blockedRuntimeProviderTypes[providerType]; label != "" { - return "当前版本暂不支持腾讯问卷" + label + "题" - } if supportedProviderTypes[providerType] || providerType == "description" { return "" } diff --git a/pkg/surveycore/internal/providers/tencent/parser_test.go b/pkg/surveycore/internal/providers/tencent/parser_test.go index 1a3736a..d1cb5c2 100644 --- a/pkg/surveycore/internal/providers/tencent/parser_test.go +++ b/pkg/surveycore/internal/providers/tencent/parser_test.go @@ -99,13 +99,17 @@ func TestTencentAttachLogicMetadata(t *testing.T) { } } -func TestParserRejectsBlockedTencentRating(t *testing.T) { +func TestParserAcceptsTencentRating(t *testing.T) { server := newTencentTestServer(t, true) parser := Parser{HTTP: rewriteClient(server.URL)} - _, err := parser.Parse(context.Background(), "https://wj.qq.com/s2/123/hashvalue/") - if err == nil || !strings.Contains(err.Error(), "请改用 v3.2.2 旧版本") { - t.Fatalf("error = %v", err) + definition, err := parser.Parse(context.Background(), "https://wj.qq.com/s2/123/hashvalue/") + if err != nil { + t.Fatal(err) + } + rating := definition.Questions[len(definition.Questions)-1] + if rating.Unsupported || rating.TypeCode != "5" || !rating.IsRating || rating.Options != 5 || rating.OptionTexts[0] != "1" { + t.Fatalf("rating = %#v", rating) } } @@ -174,7 +178,7 @@ func (c rewriteClient) DoJSON(ctx context.Context, method string, rawURL string, return json.NewDecoder(resp.Body).Decode(out) } -func newTencentTestServer(t *testing.T, blocked bool) *httptest.Server { +func newTencentTestServer(t *testing.T, includeRating bool) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -184,7 +188,7 @@ func newTencentTestServer(t *testing.T, blocked bool) *httptest.Server { writeTencentJSON(t, w, map[string]any{"code": "OK", "data": map[string]any{"title": "消费问卷 - 腾讯问卷"}}) case "/api/v2/respondent/surveys/123/questions": questions := loadTencentQuestionsFixture(t) - if blocked { + if includeRating { questions = append(questions, map[string]any{"id": "q6", "type": "star", "title": "评分", "star_begin_num": 1, "star_num": 5, "page_id": "p3", "page": 3}) } writeTencentJSON(t, w, map[string]any{"code": "OK", "data": map[string]any{"questions": questions}}) diff --git a/pkg/surveycore/internal/providers/tencent/rating_answer.go b/pkg/surveycore/internal/providers/tencent/rating_answer.go new file mode 100644 index 0000000..998a89f --- /dev/null +++ b/pkg/surveycore/internal/providers/tencent/rating_answer.go @@ -0,0 +1,56 @@ +package tencent + +import ( + "fmt" + "strings" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +func ratingAnswer(raw map[string]any, action model.AnswerAction) (map[string]any, error) { + if len(action.SelectedIndices) != 1 { + return nil, fmt.Errorf("腾讯问卷第%d题没有生成唯一评分答案", action.QuestionNum) + } + providerType := strings.TrimSpace(stringValue(raw["type"])) + score, err := ratingValue(raw, providerType, action.SelectedIndices[0], action.QuestionNum) + if err != nil { + return nil, err + } + id := firstString(raw["id"], action.QuestionID) + if id == "" { + return nil, fmt.Errorf("腾讯问卷第%d题缺少评分题 id", action.QuestionNum) + } + return map[string]any{"id": id + "-" + score, "type": providerType, "answer": score}, nil +} + +func matrixRatingAnswer(raw map[string]any, action model.AnswerAction) ([]map[string]any, error) { + rows := asMapList(raw["sub_titles"]) + if len(rows) == 0 || len(action.MatrixIndices) != len(rows) { + return nil, fmt.Errorf("腾讯问卷第%d题没有生成完整的矩阵评分答案", action.QuestionNum) + } + questionID := firstString(raw["id"], action.QuestionID) + if questionID == "" { + return nil, fmt.Errorf("腾讯问卷第%d题缺少矩阵评分题 id", action.QuestionNum) + } + answers := make([]map[string]any, 0, len(rows)) + for index, row := range rows { + score, err := ratingValue(raw, "matrix_star", action.MatrixIndices[index], action.QuestionNum) + if err != nil { + return nil, err + } + id := questionID + if rowID := strings.TrimSpace(stringValue(row["id"])); rowID != "" { + id += "-" + rowID + } + answers = append(answers, map[string]any{"id": id + "-" + score, "type": "matrix_star", "answer": "on"}) + } + return answers, nil +} + +func ratingValue(raw map[string]any, providerType string, index, questionNum int) (string, error) { + values := ratingOptionTexts(raw, providerType) + if index < 0 || index >= len(values) { + return "", fmt.Errorf("腾讯问卷第%d题评分选项超出范围", questionNum) + } + return values[index], nil +} diff --git a/pkg/surveycore/internal/providers/tencent/rating_answer_test.go b/pkg/surveycore/internal/providers/tencent/rating_answer_test.go new file mode 100644 index 0000000..ca5a089 --- /dev/null +++ b/pkg/surveycore/internal/providers/tencent/rating_answer_test.go @@ -0,0 +1,104 @@ +package tencent + +import ( + "encoding/json" + "os" + "reflect" + "testing" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +func liveRatingQuestions(t *testing.T) []map[string]any { + t.Helper() + // Public question metadata from the CI-authorized survey 26070328/fa89. + data, err := os.ReadFile("testdata/live-rating-questions.json") + if err != nil { + t.Fatal(err) + } + var questions []map[string]any + if err := json.Unmarshal(data, &questions); err != nil { + t.Fatal(err) + } + return questions +} + +func TestRatingAnswersUseScoresAndProviderIDs(t *testing.T) { + questions := liveRatingQuestions(t) + if len(questions) != 3 { + t.Fatalf("rating questions=%d", len(questions)) + } + for _, test := range []struct { + raw map[string]any + index int + id, score string + }{ + {questions[1], 0, "q-15-a899-0", "0"}, + {questions[2], 10, "q-18-b757-10", "10"}, + {map[string]any{"id": "star", "type": "star", "star_num": 5}, 0, "star-1", "1"}, + {map[string]any{"id": "custom", "type": "star", "star_num": 5, "star_begin_num": 3}, 4, "custom-7", "7"}, + } { + actual, err := questionAnswer(test.raw, model.AnswerAction{QuestionNum: 8, SelectedIndices: []int{test.index}}) + expected := map[string]any{"id": test.id, "type": test.raw["type"], "answer": test.score} + if err != nil || !reflect.DeepEqual(actual, expected) { + t.Fatalf("actual=%#v expected=%#v error=%v", actual, expected, err) + } + } + matrix, err := questionAnswer(questions[0], model.AnswerAction{QuestionNum: 4, MatrixIndices: []int{0, 1, 2, 3, 4}}) + expected := []map[string]any{ + {"id": "q-13-7d39-g-101-ae7c-1", "type": "matrix_star", "answer": "on"}, + {"id": "q-13-7d39-g-102-68b1-2", "type": "matrix_star", "answer": "on"}, + {"id": "q-13-7d39-g-103-2bc8-3", "type": "matrix_star", "answer": "on"}, + {"id": "q-13-7d39-g-104-a1bb-4", "type": "matrix_star", "answer": "on"}, + {"id": "q-13-7d39-g-105-d55c-5", "type": "matrix_star", "answer": "on"}, + } + if err != nil || !reflect.DeepEqual(matrix, expected) { + t.Fatalf("matrix=%#v error=%v", matrix, err) + } +} + +func TestRatingsRejectMissingAndOutOfRangeAnswers(t *testing.T) { + questions := liveRatingQuestions(t) + for _, action := range []model.AnswerAction{ + {}, {SelectedIndices: []int{-1}}, {SelectedIndices: []int{11}}, {SelectedIndices: []int{0, 1}}, + } { + if _, err := ratingAnswer(questions[1], action); err == nil { + t.Fatalf("accepted invalid rating: %#v", action) + } + } + for _, action := range []model.AnswerAction{ + {}, {MatrixIndices: []int{0}}, {MatrixIndices: []int{0, 1, 2, 3, 5}}, + } { + if _, err := matrixRatingAnswer(questions[0], action); err == nil { + t.Fatalf("accepted invalid matrix rating: %#v", action) + } + } +} + +func TestLiveRatingsParseWithoutFalseDisplayConstraints(t *testing.T) { + definition, err := buildDefinition(liveRatingQuestions(t), "CI rating fixture") + if err != nil { + t.Fatal(err) + } + for _, question := range definition.Questions { + if question.Unsupported || question.HasDisplayCondition || question.UnsupportedReason != "" { + t.Fatalf("supported visible rating was blocked: %#v", question) + } + } + if definition.Questions[0].Rows != 5 || definition.Questions[0].Options != 5 || definition.Questions[1].OptionTexts[0] != "0" || definition.Questions[1].OptionTexts[10] != "10" { + t.Fatalf("rating bounds=%#v", definition.Questions) + } +} + +func TestHiddenLogicDistinguishesFalseFromUnresolvedConstraints(t *testing.T) { + for _, value := range []any{nil, false, "", 0, []any{}, map[string]any{}} { + if hasHiddenLogic(value) { + t.Fatalf("empty hidden value became a display constraint: %#v", value) + } + } + for _, value := range []any{true, "expression", []any{"rule"}, map[string]any{"rule": true}} { + if !hasHiddenLogic(value) { + t.Fatalf("unresolved hidden condition was ignored: %#v", value) + } + } +} diff --git a/pkg/surveycore/internal/providers/tencent/submit_body.go b/pkg/surveycore/internal/providers/tencent/submit_body.go index e01a799..c3adcb4 100644 --- a/pkg/surveycore/internal/providers/tencent/submit_body.go +++ b/pkg/surveycore/internal/providers/tencent/submit_body.go @@ -16,9 +16,6 @@ func buildSubmitBody(request *model.SubmissionRequest, surveyID string, hashValu if question.IsDescription { continue } - if label := blockedRuntimeProviderTypes[question.ProviderType]; label != "" { - return nil, runerror.Wrap(runerror.KindUnsupported, fmt.Errorf("腾讯问卷第%d题暂不支持:%s", question.Num, label)) - } if !supportedProviderTypes[question.ProviderType] { return nil, runerror.Wrap(runerror.KindUnsupported, fmt.Errorf("腾讯问卷第%d题暂不支持:%s", question.Num, firstString(question.ProviderType, question.TypeCode, "unknown"))) } @@ -107,6 +104,10 @@ func questionAnswer(raw map[string]any, action model.AnswerAction) (any, error) }, nil case "matrix_radio": return matrixAnswer(raw, action) + case "nps", "star": + return ratingAnswer(raw, action) + case "matrix_star": + return matrixRatingAnswer(raw, action) default: return choiceAnswer(raw, action) } diff --git a/pkg/surveycore/internal/providers/tencent/testdata/live-rating-questions.json b/pkg/surveycore/internal/providers/tencent/testdata/live-rating-questions.json new file mode 100644 index 0000000..a68a6b1 --- /dev/null +++ b/pkg/surveycore/internal/providers/tencent/testdata/live-rating-questions.json @@ -0,0 +1,125 @@ +[ + { + "description": "请对以下就业影响因素进行评分(1-5分)", + "display_type": 0, + "goto": "", + "group_id": 0, + "hidden": false, + "id": "q-13-7d39", + "identity": "", + "is_custom_show": false, + "is_other": false, + "layout": 0, + "other_str": "", + "page": 1024, + "page_id": "p-1-abcd", + "prefill": false, + "refer": "", + "required": true, + "revert_sort": 0, + "sensitive": false, + "sort": 65539, + "star_begin_num": 1, + "star_middle_num": 7, + "star_num": 5, + "star_show": "可能性", + "star_show_custom_end": "非常重要", + "star_show_custom_middle": "中间", + "star_show_custom_start": "非常不重要", + "star_type": "", + "sub_titles": [ + { + "id": "g-101-ae7c", + "text": "薪资福利" + }, + { + "id": "g-102-68b1", + "text": "工作稳定性" + }, + { + "id": "g-103-2bc8", + "text": "职业发展空间" + }, + { + "id": "g-104-a1bb", + "text": "工作地点" + }, + { + "id": "g-105-d55c", + "text": "企业文化匹配度" + } + ], + "sub_type": 0, + "title": "就业因素重要性评价", + "total_scores": 0, + "type": "matrix_star" + }, + { + "description": "您多大可能选择母校所在城市就业?", + "display_type": 0, + "enable_star_attach_tags": false, + "goto": "", + "group_id": 0, + "hidden": false, + "id": "q-15-a899", + "identity": "", + "is_custom_show": false, + "is_other": false, + "option_displays": null, + "option_gotos": null, + "other_str": "", + "page": 1024, + "page_id": "p-1-abcd", + "prefill": false, + "refer": "", + "required": true, + "revert_sort": 0, + "sensitive": false, + "sort": 65543, + "star_attach_tags": [], + "star_begin_num": 0, + "star_middle_num": 7, + "star_num": 11, + "star_show_custom_end": "非常可能", + "star_show_custom_middle": "中间", + "star_show_custom_start": "绝无可能", + "sub_type": 0, + "title": "留校工作推荐度", + "total_scores": 0, + "type": "nps" + }, + { + "description": "0-10分评估您的就业信心", + "display_type": 0, + "enable_star_attach_tags": false, + "goto": "", + "group_id": 0, + "hidden": false, + "id": "q-18-b757", + "identity": "", + "is_custom_show": false, + "is_other": false, + "option_displays": null, + "option_gotos": null, + "other_str": "", + "page": 2048, + "page_id": "p-2-xIOc", + "prefill": false, + "refer": "", + "required": true, + "revert_sort": 0, + "sensitive": false, + "sort": 32768, + "star_attach_tags": [], + "star_begin_num": 0, + "star_middle_num": 7, + "star_num": 11, + "star_show_custom_end": "信心十足", + "star_show_custom_middle": "中间", + "star_show_custom_start": "毫无信心", + "sub_type": 0, + "title": "整体就业信心指数", + "total_scores": 0, + "type": "nps" + } +] diff --git a/pkg/surveycore/runtime/tencent_rating_test.go b/pkg/surveycore/runtime/tencent_rating_test.go new file mode 100644 index 0000000..5eca324 --- /dev/null +++ b/pkg/surveycore/runtime/tencent_rating_test.go @@ -0,0 +1,93 @@ +package runtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/SurveyController/SurveyCore/pkg/surveycore/model" +) + +func TestTencentDefaultConfigSubmitsRatingsAndConfirmsPersistence(t *testing.T) { + data, err := os.ReadFile("../internal/providers/tencent/testdata/live-rating-questions.json") + if err != nil { + t.Fatal(err) + } + var questions []map[string]any + if err := json.Unmarshal(data, &questions); err != nil { + t.Fatal(err) + } + questions = append(questions, map[string]any{"id": "star", "type": "star", "title": "星级", "star_num": 5, "page_id": "p3", "page": 3, "hidden": false}) + var posts atomic.Int32 + answers := make(chan map[string]any, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + response := map[string]any{} + switch { + case strings.HasSuffix(request.URL.Path, "/session"): + response = map[string]any{"answer_session_id": "rating-session", "answer_session": map[string]any{"last_answer_id": posts.Load()}} + case strings.HasSuffix(request.URL.Path, "/meta"): + response = map[string]any{"title": "评分回归"} + case strings.HasSuffix(request.URL.Path, "/questions"): + response = map[string]any{"questions": questions} + case strings.HasSuffix(request.URL.Path, "/answers"): + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Errorf("invalid submit JSON: %v", err) + http.Error(w, "bad JSON", http.StatusBadRequest) + return + } + answers <- body + posts.Add(1) + response = map[string]any{"answer_hash": "recorded-rating"} + default: + t.Errorf("unexpected path: %s", request.URL.Path) + http.NotFound(w, request) + return + } + writeTestJSON(t, w, map[string]any{"code": "OK", "data": response}) + })) + defer server.Close() + client := New(WithHTTPClient(rewriteTencentHTTPClient(server.URL))) + config, err := client.DefaultConfig(context.Background(), "https://wj.qq.com/s2/123/hash/") + if err != nil { + t.Fatal(err) + } + if len(config.AnswerPlan.Strategies) != 4 || config.AnswerPlan.Strategies[0].QuestionType != model.QuestionKindMatrix || config.AnswerPlan.Strategies[1].QuestionType != model.QuestionKindScore { + t.Fatalf("default rating strategies=%#v", config.AnswerPlan.Strategies) + } + config.ExecutionPlan.Target, config.ExecutionPlan.Threads = 1, 1 + config.PsychometricPolicy.Enabled = false + for index := range config.AnswerPlan.Strategies { + entry := &config.AnswerPlan.Strategies[index] + weights := make([]float64, entry.OptionCount) + weights[0] = 1 + entry.DistributionMode = "random" + entry.Probabilities = model.OptionWeights(weights...) + entry.CustomWeights = model.WeightTable{} + } + result, err := client.Run(context.Background(), config) + if err != nil || result == nil || result.Success != 1 || result.Fail != 0 || posts.Load() != 1 { + t.Fatalf("result=%#v error=%v posts=%d", result, err, posts.Load()) + } + body := <-answers + pages := body["answer_survey"].(map[string]any)["pages"].([]any) + ids := map[string]bool{} + for _, page := range pages { + for _, answer := range page.(map[string]any)["questions"].([]any) { + ids[answer.(map[string]any)["id"].(string)] = true + } + } + for _, id := range []string{"q-15-a899-0", "q-18-b757-0", "star-1", "q-13-7d39-g-101-ae7c-1", "q-13-7d39-g-105-d55c-1"} { + if !ids[id] { + t.Errorf("missing rating answer %q in %#v", id, ids) + } + } + if len(ids) != 8 { + t.Fatalf("expected every matrix row and scalar score, got %#v", ids) + } +}