選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

512 行
18KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "testing"
  6. "time"
  7. "qctextbuilder/internal/domain"
  8. "qctextbuilder/internal/qcclient"
  9. )
  10. func TestSuggestFieldValues_FillsEmptyMappedFields(t *testing.T) {
  11. t.Parallel()
  12. fields := []domain.TemplateField{
  13. {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  14. {Path: "services.servicesTitle_r4830_8", KeyName: "servicesTitle_r4830_8", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionServices},
  15. {Path: "services.servicesDescription_r4830_9", KeyName: "servicesDescription_r4830_9", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionServices},
  16. {Path: "text.buttonText_c1165_1", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  17. }
  18. result := SuggestFieldValues(SuggestionRequest{
  19. Fields: fields,
  20. GlobalData: map[string]any{
  21. "companyName": "Muster AG",
  22. "businessType": "Solar",
  23. },
  24. DraftContext: &domain.DraftContext{
  25. LLM: domain.DraftLLMContext{
  26. WebsiteSummary: "Wir planen und installieren Solaranlagen fuer KMU und Privatkunden.",
  27. StyleProfile: domain.DraftStyleProfile{
  28. ContentTone: "professionell",
  29. },
  30. },
  31. },
  32. Existing: map[string]string{},
  33. })
  34. if _, ok := result.ByFieldPath["text.textTitle_m1710_1"]; !ok {
  35. t.Fatalf("expected hero title suggestion")
  36. }
  37. if got := result.ByFieldPath["text.buttonText_c1165_1"].Value; got == "" {
  38. t.Fatalf("expected cta suggestion")
  39. }
  40. if got := result.ByFieldPath["services.servicesDescription_r4830_9"].Slot; got != "service_items[0].description" {
  41. t.Fatalf("unexpected slot: %q", got)
  42. }
  43. }
  44. func TestSuggestFieldValues_RespectsExistingValues(t *testing.T) {
  45. t.Parallel()
  46. fields := []domain.TemplateField{
  47. {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  48. }
  49. result := SuggestFieldValues(SuggestionRequest{
  50. Fields: fields,
  51. GlobalData: map[string]any{
  52. "companyName": "Muster AG",
  53. },
  54. Existing: map[string]string{
  55. "text.textTitle_m1710_1": "Schon gesetzt",
  56. },
  57. })
  58. if len(result.Suggestions) != 0 {
  59. t.Fatalf("expected no suggestions, got %d", len(result.Suggestions))
  60. }
  61. }
  62. func TestGenerateAllSuggestions_IncludesFilledFields(t *testing.T) {
  63. t.Parallel()
  64. fields := []domain.TemplateField{
  65. {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  66. }
  67. state := GenerateAllSuggestions(context.Background(), nil, SuggestionRequest{
  68. Fields: fields,
  69. GlobalData: map[string]any{
  70. "companyName": "Muster AG",
  71. },
  72. Existing: map[string]string{
  73. "text.textTitle_m1710_1": "Bereits gesetzt",
  74. },
  75. }, domain.DraftSuggestionState{}, time.Now().UTC())
  76. if _, ok := state.ByFieldPath["text.textTitle_m1710_1"]; !ok {
  77. t.Fatalf("expected suggestion for filled field")
  78. }
  79. }
  80. func TestApplySuggestionsToEmptyFields_DoesNotOverwriteExisting(t *testing.T) {
  81. t.Parallel()
  82. now := time.Now().UTC()
  83. values, state := ApplySuggestionsToEmptyFields(map[string]string{
  84. "field.hero": "Custom",
  85. }, domain.DraftSuggestionState{
  86. ByFieldPath: map[string]domain.DraftSuggestion{
  87. "field.hero": {
  88. FieldPath: "field.hero",
  89. Value: "Suggestion",
  90. Status: domain.DraftSuggestionStatusSuggested,
  91. },
  92. "field.cta": {
  93. FieldPath: "field.cta",
  94. Value: "Jetzt anfragen",
  95. Status: domain.DraftSuggestionStatusSuggested,
  96. },
  97. },
  98. }, now)
  99. if got := values["field.hero"]; got != "Custom" {
  100. t.Fatalf("expected existing value unchanged, got %q", got)
  101. }
  102. if got := values["field.cta"]; got != "Jetzt anfragen" {
  103. t.Fatalf("expected empty value filled, got %q", got)
  104. }
  105. if got := state.ByFieldPath["field.hero"].Status; got != domain.DraftSuggestionStatusSuggested {
  106. t.Fatalf("expected hero status unchanged, got %q", got)
  107. }
  108. if got := state.ByFieldPath["field.cta"].Status; got != domain.DraftSuggestionStatusApplied {
  109. t.Fatalf("expected cta status applied, got %q", got)
  110. }
  111. }
  112. func TestApplyAllSuggestions_OverwritesExisting(t *testing.T) {
  113. t.Parallel()
  114. now := time.Now().UTC()
  115. values, state := ApplyAllSuggestions(map[string]string{
  116. "field.hero": "Custom",
  117. }, domain.DraftSuggestionState{
  118. ByFieldPath: map[string]domain.DraftSuggestion{
  119. "field.hero": {
  120. FieldPath: "field.hero",
  121. Value: "Suggestion",
  122. Status: domain.DraftSuggestionStatusSuggested,
  123. },
  124. "field.cta": {
  125. FieldPath: "field.cta",
  126. Value: "Jetzt anfragen",
  127. Status: domain.DraftSuggestionStatusSuggested,
  128. },
  129. },
  130. }, now)
  131. if got := values["field.hero"]; got != "Suggestion" {
  132. t.Fatalf("expected existing value overwritten, got %q", got)
  133. }
  134. if got := values["field.cta"]; got != "Jetzt anfragen" {
  135. t.Fatalf("expected cta applied, got %q", got)
  136. }
  137. if got := state.ByFieldPath["field.hero"].Status; got != domain.DraftSuggestionStatusApplied {
  138. t.Fatalf("expected hero status applied, got %q", got)
  139. }
  140. if got := state.ByFieldPath["field.cta"].Status; got != domain.DraftSuggestionStatusApplied {
  141. t.Fatalf("expected cta status applied, got %q", got)
  142. }
  143. }
  144. func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) {
  145. t.Parallel()
  146. fields := []domain.TemplateField{
  147. {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  148. {Path: "text.buttonText_c1165_1", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  149. }
  150. current := domain.DraftSuggestionState{
  151. ByFieldPath: map[string]domain.DraftSuggestion{
  152. "text.textTitle_m1710_1": {FieldPath: "text.textTitle_m1710_1", Value: "Old Hero"},
  153. "text.buttonText_c1165_1": {FieldPath: "text.buttonText_c1165_1", Value: "Old CTA"},
  154. },
  155. }
  156. updated := RegenerateFieldSuggestion(context.Background(), nil, SuggestionRequest{
  157. Fields: fields,
  158. GlobalData: map[string]any{
  159. "companyName": "Muster AG",
  160. },
  161. }, current, "text.buttonText_c1165_1", time.Now().UTC())
  162. if got := updated.ByFieldPath["text.textTitle_m1710_1"].Value; got != "Old Hero" {
  163. t.Fatalf("expected untargeted field unchanged, got %q", got)
  164. }
  165. if got := updated.ByFieldPath["text.buttonText_c1165_1"].Value; got == "" || got == "Old CTA" {
  166. t.Fatalf("expected target field regenerated, got %q", got)
  167. }
  168. }
  169. func TestGenerateAllSuggestions_UsesGeneratorFallbackWhenPrimaryPartial(t *testing.T) {
  170. t.Parallel()
  171. fields := []domain.TemplateField{
  172. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  173. {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  174. }
  175. generator := NewCompositeSuggestionGenerator(
  176. NewLLMSuggestionGenerator(&stubQCClient{
  177. generateContent: qcclient.GenerateContentData{
  178. "text": {
  179. "textTitle_m1710_1": "LLM Hero",
  180. },
  181. },
  182. }),
  183. NewRuleBasedSuggestionGenerator(),
  184. )
  185. state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{
  186. TemplateID: 101,
  187. Fields: fields,
  188. GlobalData: map[string]any{"companyName": "Muster AG"},
  189. Existing: map[string]string{},
  190. }, domain.DraftSuggestionState{}, time.Now().UTC())
  191. hero := state.ByFieldPath["text.textTitle_m1710_1"]
  192. if hero.Source != domain.DraftSuggestionSourceLLM {
  193. t.Fatalf("expected hero source llm, got %q", hero.Source)
  194. }
  195. cta := state.ByFieldPath["text.buttonText_c1165_1"]
  196. if cta.Source != domain.DraftSuggestionSourceFallbackRuleBased {
  197. t.Fatalf("expected cta source fallback rule-based, got %q", cta.Source)
  198. }
  199. }
  200. func TestGenerateAllSuggestions_FallsBackWhenLLMReturnsInvalidValueType(t *testing.T) {
  201. t.Parallel()
  202. fields := []domain.TemplateField{
  203. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  204. }
  205. generator := NewCompositeSuggestionGenerator(
  206. NewLLMSuggestionGenerator(&stubQCClient{
  207. generateContent: qcclient.GenerateContentData{
  208. "text": {
  209. "textTitle_m1710_1": true,
  210. },
  211. },
  212. }),
  213. NewRuleBasedSuggestionGenerator(),
  214. )
  215. state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{
  216. TemplateID: 202,
  217. Fields: fields,
  218. GlobalData: map[string]any{"companyName": "Muster AG"},
  219. Existing: map[string]string{},
  220. }, domain.DraftSuggestionState{}, time.Now().UTC())
  221. hero := state.ByFieldPath["text.textTitle_m1710_1"]
  222. if hero.Value == "" {
  223. t.Fatalf("expected fallback suggestion value")
  224. }
  225. if hero.Source != domain.DraftSuggestionSourceFallbackRuleBased {
  226. t.Fatalf("expected fallback source, got %q", hero.Source)
  227. }
  228. }
  229. func TestGenerateAllSuggestions_PreservesSourceFromByFieldPathOnStateApply(t *testing.T) {
  230. t.Parallel()
  231. fields := []domain.TemplateField{
  232. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  233. {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  234. }
  235. generator := &stubSuggestionGenerator{
  236. result: SuggestionResult{
  237. Suggestions: []Suggestion{
  238. {FieldPath: "text.textTitle_m1710_1", Value: "Provider Hero", Source: ""},
  239. {FieldPath: "text.buttonText_c1165_1", Value: "Fallback CTA", Source: ""},
  240. },
  241. ByFieldPath: map[string]Suggestion{
  242. "text.textTitle_m1710_1": {FieldPath: "text.textTitle_m1710_1", Value: "Provider Hero", Source: domain.LLMProviderOpenAI},
  243. "text.buttonText_c1165_1": {FieldPath: "text.buttonText_c1165_1", Value: "Fallback CTA", Source: ""},
  244. },
  245. },
  246. }
  247. state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{
  248. Fields: fields,
  249. Existing: map[string]string{},
  250. IncludeFilled: true,
  251. }, domain.DraftSuggestionState{}, time.Now().UTC())
  252. if got := state.ByFieldPath["text.textTitle_m1710_1"].Source; got != domain.LLMProviderOpenAI {
  253. t.Fatalf("expected provider source preserved from generated result, got %q", got)
  254. }
  255. if got := state.ByFieldPath["text.buttonText_c1165_1"].Source; got != "unknown" {
  256. t.Fatalf("expected unknown source only when suggestion source is empty, got %q", got)
  257. }
  258. }
  259. func TestGenerateAllSuggestions_ReplacesStaleRuleBasedSourceWithProviderSource(t *testing.T) {
  260. t.Parallel()
  261. fields := []domain.TemplateField{
  262. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  263. }
  264. current := domain.DraftSuggestionState{
  265. ByFieldPath: map[string]domain.DraftSuggestion{
  266. "text.textTitle_m1710_1": {
  267. FieldPath: "text.textTitle_m1710_1",
  268. Value: "Old fallback hero",
  269. Source: domain.DraftSuggestionSourceFallbackRuleBased,
  270. Status: domain.DraftSuggestionStatusSuggested,
  271. },
  272. },
  273. }
  274. generator := &stubSuggestionGenerator{
  275. result: SuggestionResult{
  276. Suggestions: []Suggestion{
  277. {FieldPath: "text.textTitle_m1710_1", Value: "Provider Hero", Source: domain.LLMProviderOpenAI},
  278. },
  279. ByFieldPath: map[string]Suggestion{
  280. "text.textTitle_m1710_1": {FieldPath: "text.textTitle_m1710_1", Value: "Provider Hero", Source: domain.LLMProviderOpenAI},
  281. },
  282. },
  283. }
  284. state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{
  285. Fields: fields,
  286. Existing: map[string]string{},
  287. IncludeFilled: true,
  288. }, current, time.Now().UTC())
  289. hero := state.ByFieldPath["text.textTitle_m1710_1"]
  290. if hero.Source != domain.LLMProviderOpenAI {
  291. t.Fatalf("expected stale fallback source to be replaced by provider source, got %q", hero.Source)
  292. }
  293. if hero.Value != "Provider Hero" {
  294. t.Fatalf("expected provider value to replace stale fallback value, got %q", hero.Value)
  295. }
  296. }
  297. func TestCompositeSuggestionGenerator_NoFallbackWhenPrimaryCoversAllTargets(t *testing.T) {
  298. t.Parallel()
  299. fields := []domain.TemplateField{
  300. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  301. {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  302. }
  303. primary := &stubSuggestionGenerator{
  304. result: SuggestionResult{
  305. Suggestions: []Suggestion{
  306. {FieldPath: "text.textTitle_m1710_1", Value: "Primary Hero", Source: domain.DraftSuggestionSourceLLM},
  307. {FieldPath: "text.buttonText_c1165_1", Value: "Primary CTA", Source: domain.DraftSuggestionSourceLLM},
  308. },
  309. },
  310. }
  311. fallback := &stubSuggestionGenerator{
  312. result: SuggestionResult{
  313. Suggestions: []Suggestion{
  314. {FieldPath: "text.textTitle_m1710_1", Value: "Fallback Hero", Source: domain.DraftSuggestionSourceFallbackRuleBased},
  315. },
  316. },
  317. }
  318. generator := NewCompositeSuggestionGenerator(primary, fallback)
  319. result, err := generator.Generate(context.Background(), SuggestionRequest{
  320. Fields: fields,
  321. Existing: map[string]string{},
  322. IncludeFilled: true,
  323. })
  324. if err != nil {
  325. t.Fatalf("unexpected error: %v", err)
  326. }
  327. if fallback.callCount != 0 {
  328. t.Fatalf("expected no fallback call, got %d", fallback.callCount)
  329. }
  330. if len(result.Suggestions) != 2 {
  331. t.Fatalf("expected 2 suggestions, got %d", len(result.Suggestions))
  332. }
  333. }
  334. func TestCompositeSuggestionGenerator_FallbackReceivesOnlyMissingTargets(t *testing.T) {
  335. t.Parallel()
  336. fields := []domain.TemplateField{
  337. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  338. {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  339. }
  340. primary := &stubSuggestionGenerator{
  341. result: SuggestionResult{
  342. Suggestions: []Suggestion{
  343. {FieldPath: "text.textTitle_m1710_1", Value: "Primary Hero", Source: domain.DraftSuggestionSourceLLM},
  344. },
  345. },
  346. }
  347. fallback := &stubSuggestionGenerator{
  348. result: SuggestionResult{
  349. Suggestions: []Suggestion{
  350. {FieldPath: "text.buttonText_c1165_1", Value: "Fallback CTA", Source: domain.DraftSuggestionSourceFallbackRuleBased},
  351. },
  352. },
  353. }
  354. generator := NewCompositeSuggestionGenerator(primary, fallback)
  355. result, err := generator.Generate(context.Background(), SuggestionRequest{
  356. Fields: fields,
  357. Existing: map[string]string{},
  358. IncludeFilled: true,
  359. })
  360. if err != nil {
  361. t.Fatalf("unexpected error: %v", err)
  362. }
  363. if fallback.callCount != 1 {
  364. t.Fatalf("expected one fallback call, got %d", fallback.callCount)
  365. }
  366. if len(fallback.lastReq.Fields) != 1 || fallback.lastReq.Fields[0].Path != "text.buttonText_c1165_1" {
  367. t.Fatalf("expected fallback request only for missing CTA field, got %+v", fallback.lastReq.Fields)
  368. }
  369. if got := result.ByFieldPath["text.textTitle_m1710_1"].Source; got != domain.DraftSuggestionSourceLLM {
  370. t.Fatalf("expected primary source on hero, got %q", got)
  371. }
  372. if got := result.ByFieldPath["text.buttonText_c1165_1"].Source; got != domain.DraftSuggestionSourceFallbackRuleBased {
  373. t.Fatalf("expected fallback source on cta, got %q", got)
  374. }
  375. }
  376. func TestCompositeSuggestionGenerator_PrimaryWinsOverFallbackForSameField(t *testing.T) {
  377. t.Parallel()
  378. fields := []domain.TemplateField{
  379. {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
  380. {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
  381. }
  382. primary := &stubSuggestionGenerator{
  383. result: SuggestionResult{
  384. Suggestions: []Suggestion{
  385. {FieldPath: "text.textTitle_m1710_1", Value: "Primary Hero", Source: domain.DraftSuggestionSourceLLM},
  386. },
  387. },
  388. }
  389. fallback := &stubSuggestionGenerator{
  390. result: SuggestionResult{
  391. Suggestions: []Suggestion{
  392. {FieldPath: "text.textTitle_m1710_1", Value: "Fallback Hero", Source: domain.DraftSuggestionSourceFallbackRuleBased},
  393. {FieldPath: "text.buttonText_c1165_1", Value: "Fallback CTA", Source: domain.DraftSuggestionSourceFallbackRuleBased},
  394. },
  395. },
  396. }
  397. generator := NewCompositeSuggestionGenerator(primary, fallback)
  398. result, err := generator.Generate(context.Background(), SuggestionRequest{
  399. Fields: fields,
  400. Existing: map[string]string{},
  401. IncludeFilled: true,
  402. })
  403. if err != nil {
  404. t.Fatalf("unexpected error: %v", err)
  405. }
  406. if got := result.ByFieldPath["text.textTitle_m1710_1"]; got.Value != "Primary Hero" || got.Source != domain.DraftSuggestionSourceLLM {
  407. t.Fatalf("expected primary hero suggestion to win, got %+v", got)
  408. }
  409. }
  410. type stubQCClient struct {
  411. generateContent qcclient.GenerateContentData
  412. generateErr error
  413. }
  414. func (s *stubQCClient) Health(context.Context) error { return nil }
  415. func (s *stubQCClient) ListAITemplates(context.Context) ([]qcclient.Template, error) {
  416. return nil, nil
  417. }
  418. func (s *stubQCClient) GetTemplate(context.Context, int64) (*qcclient.Template, error) {
  419. return nil, nil
  420. }
  421. func (s *stubQCClient) GetTemplateSchema(context.Context, int64) (json.RawMessage, error) {
  422. return nil, nil
  423. }
  424. func (s *stubQCClient) GenerateContent(context.Context, qcclient.GenerateContentRequest) (qcclient.GenerateContentData, json.RawMessage, error) {
  425. if s.generateErr != nil {
  426. return nil, nil, s.generateErr
  427. }
  428. return s.generateContent, nil, nil
  429. }
  430. func (s *stubQCClient) CreateSite(context.Context, qcclient.CreateSiteRequest) (*qcclient.CreateSiteResponseData, json.RawMessage, error) {
  431. return nil, nil, nil
  432. }
  433. func (s *stubQCClient) GetJob(context.Context, int64) (*qcclient.JobStatusData, json.RawMessage, error) {
  434. return nil, nil, nil
  435. }
  436. func (s *stubQCClient) GetEditorURL(context.Context, int64) (*qcclient.SiteEditorLoginData, json.RawMessage, error) {
  437. return nil, nil, nil
  438. }
  439. type stubSuggestionGenerator struct {
  440. result SuggestionResult
  441. err error
  442. callCount int
  443. lastReq SuggestionRequest
  444. }
  445. func (s *stubSuggestionGenerator) Generate(_ context.Context, req SuggestionRequest) (SuggestionResult, error) {
  446. s.callCount++
  447. s.lastReq = req
  448. if s.err != nil {
  449. return SuggestionResult{}, s.err
  450. }
  451. return s.result, nil
  452. }