Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions CI/live_tests/provider_submission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ SurveyCore 是 SurveyController 使用的问卷解析、配置、提交、代理
- [x] Credamo 见数
- [ ] ...(欢迎贡献!)

腾讯问卷支持单选、多选、下拉、文本、矩阵单选,以及 NPS、星级评分和矩阵评分。评分按平台分值提交,支持 0 分。

## 使用方法

### 环境要求
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 不会生成显示约束。

Expand Down
19 changes: 18 additions & 1 deletion pkg/surveycore/internal/providers/tencent/logic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
24 changes: 6 additions & 18 deletions pkg/surveycore/internal/providers/tencent/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")}
}
34 changes: 18 additions & 16 deletions pkg/surveycore/internal/providers/tencent/normalize_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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 ""
}
Expand Down
16 changes: 10 additions & 6 deletions pkg/surveycore/internal/providers/tencent/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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}})
Expand Down
56 changes: 56 additions & 0 deletions pkg/surveycore/internal/providers/tencent/rating_answer.go
Original file line number Diff line number Diff line change
@@ -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
}
104 changes: 104 additions & 0 deletions pkg/surveycore/internal/providers/tencent/rating_answer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading