package seoser import ( "encoding/json" "html/template" "testing" "videoweb/app/service/configser" "videoweb/app/service/playletser" ) func TestPageJSONLD_DetailIsValidJSON(t *testing.T) { detail := &playletser.PlayletInfoResp{ ID: 12, Slug: "test-show", Title: "测试短剧", Desc: "剧情简介", Cover: "https://cdn.example.com/cover.jpg", OnlineAt: "2026-01-02 15:04:05", TotalEpisode: 30, TagNames: []string{"都市"}, } raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{}) var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("json-ld invalid: %v\n%s", err, raw) } graph, _ := parsed["@graph"].([]any) if len(graph) < 3 { t.Fatalf("expected organization + website + detail nodes, got %d", len(graph)) } } func TestPageJSONLD_PlayHasVideoObject(t *testing.T) { playlet := &playletser.PlayletInfoResp{ ID: 1, Slug: "example-show", Title: "剧", Cover: "https://cdn.example.com/a.jpg", MediaCreatedAt: "2026-03-01 08:30:00", MediaUpdatedAt: "2026-03-02 09:45:00", TotalEpisode: 12, TagNames: []string{"都市", "甜宠"}, } chapter := &playletser.PlayletChapterInfoResp{ ID: 9, PlayletID: 1, CurrentEpisode: 2, MediaTime: 90, MediaSize: 2048, Width: 1080, Height: 1920, VideoURL: "https://cdn.example.com/episode-2.m3u8", CountBrowse: 321, } raw := PageJSONLD("https://www.example.com", "https://www.example.com/play/example-show/9", "/play/example-show/9", map[string]any{ "playlet": playlet, "chapter": chapter, }, configser.AppConfigVM{}) var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("json-ld invalid: %v", err) } found := false foundSeries := false foundEpisode := false for _, node := range parsed["@graph"].([]any) { m, _ := node.(map[string]any) if m["@type"] == "TVSeries" { foundSeries = true if m["numberOfEpisodes"] != float64(12) || m["datePublished"] == nil { t.Fatalf("TVSeries missing episode/date fields: %v", m) } } if m["@type"] == "TVEpisode" { foundEpisode = true if m["episodeNumber"] != float64(2) || m["associatedMedia"] == nil { t.Fatalf("TVEpisode missing episode/media relation: %v", m) } } if m["@type"] == "VideoObject" { found = true if m["uploadDate"] == nil || m["thumbnailUrl"] == nil { t.Fatalf("VideoObject missing required fields: %v", m) } if m["uploadDate"] != "2026-03-01T08:30:00+08:00" { t.Fatalf("VideoObject uploadDate = %v", m["uploadDate"]) } if m["dateModified"] != "2026-03-02T09:45:00+08:00" { t.Fatalf("VideoObject dateModified = %v", m["dateModified"]) } if m["contentUrl"] != "https://cdn.example.com/episode-2.m3u8" { t.Fatalf("VideoObject contentUrl = %v", m["contentUrl"]) } if m["duration"] != "PT90S" || m["episodeNumber"] != float64(2) { t.Fatalf("VideoObject missing duration/episode: %v", m) } if m["contentRating"] != "adult" || m["isFamilyFriendly"] != false { t.Fatalf("VideoObject missing adult flags: %v", m) } if m["partOfSeries"] == nil || m["genre"] == nil { t.Fatalf("VideoObject missing series/genre relation: %v", m) } if _, exists := m["embedUrl"]; exists { t.Fatalf("landing page must not be emitted as embedUrl: %v", m["embedUrl"]) } if m["interactionStatistic"] == nil { t.Fatalf("VideoObject missing interactionStatistic: %v", m) } } } if !found { t.Fatal("missing VideoObject") } if !foundSeries || !foundEpisode { t.Fatalf("missing TVSeries/TVEpisode: series=%v episode=%v", foundSeries, foundEpisode) } } func TestPageJSONLD_PaginatedCollectionHasItemList(t *testing.T) { raw := PageJSONLD("https://www.example.com", "https://www.example.com/list/ai-short-play/page/2", "/list/ai-short-play/page/2", map[string]any{ "title": "短剧第2页", "rankOffset": 20, "list": []playletser.CommonData{ {ID: 1, Slug: "first"}, {ID: 2, Slug: "second"}, }, }, configser.AppConfigVM{}) graph := parseGraph(t, raw) page := findNodeByType(t, graph, "CollectionPage") if page["mainEntity"] == nil { t.Fatalf("CollectionPage missing ItemList reference: %v", page) } items := findNodeByType(t, graph, "ItemList") entries, _ := items["itemListElement"].([]any) if len(entries) != 2 { t.Fatalf("ItemList entries = %d", len(entries)) } first, _ := entries[0].(map[string]any) if first["position"] != float64(21) || first["url"] != "https://www.example.com/detail/first" { t.Fatalf("first ItemList entry = %v", first) } } func TestPageJSONLD_FAQMatchesVisibleItems(t *testing.T) { raw := PageJSONLD("https://www.example.com", "https://www.example.com/home-road", "/home-road", map[string]any{ "title": "回家的路", "faqItems": []FAQItem{ {Question: "入口打不开怎么办?", Answer: "请切换备用入口。"}, {Question: "如何确认安全?", Answer: "仅使用官方发布的地址。"}, }, }, configser.AppConfigVM{}) graph := parseGraph(t, raw) faq := findNodeByType(t, graph, "FAQPage") questions, _ := faq["mainEntity"].([]any) if len(questions) != 2 { t.Fatalf("FAQ questions = %d", len(questions)) } question, _ := questions[0].(map[string]any) if question["name"] != "入口打不开怎么办?" { t.Fatalf("FAQ question = %v", question) } findNodeByType(t, graph, "BreadcrumbList") } func TestPageJSONLD_DetailBreadcrumbMatchesVisibleHierarchy(t *testing.T) { detail := &playletser.PlayletInfoResp{ID: 12, Slug: "test-show", Title: "测试短剧"} raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{}) crumb := findNodeByType(t, parseGraph(t, raw), "BreadcrumbList") items, _ := crumb["itemListElement"].([]any) if len(items) != 3 { t.Fatalf("detail breadcrumb items = %d", len(items)) } middle, _ := items[1].(map[string]any) if middle["name"] != "短剧" || middle["item"] != "https://www.example.com/list/ai-short-play" { t.Fatalf("detail breadcrumb middle item = %v", middle) } } func TestPageJSONLD_SkipNoindex(t *testing.T) { raw := PageJSONLD("https://www.example.com", "", "/setting", map[string]any{"title": "账号设置"}, configser.AppConfigVM{}) if string(raw) != "" { t.Fatalf("noindex page should not emit json-ld, got %s", raw) } } func TestIsIndexablePath(t *testing.T) { if !IsIndexablePath("/") || !IsIndexablePath("/detail/example-show") || !IsIndexablePath("/topic/editor-picks") || !IsIndexablePath("/help-center") { t.Fatal("public pages should be indexable") } if IsIndexablePath("/setting") || IsIndexablePath("/user-center") || IsIndexablePath("/search") || IsIndexablePath("/richtext/custom") || IsIndexablePath("/privacy-policy") || IsIndexablePath("/user-agreement") { t.Fatal("private pages should not be indexable") } } func parseGraph(t *testing.T, raw template.JS) []any { t.Helper() var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("json-ld invalid: %v\n%s", err, raw) } graph, _ := parsed["@graph"].([]any) return graph } func findNodeByType(t *testing.T, graph []any, typ string) map[string]any { t.Helper() for _, node := range graph { m, _ := node.(map[string]any) if m["@type"] == typ { return m } } t.Fatalf("missing %s node in %v", typ, graph) return nil }