package schema import ( "context" "encoding/json" "strings" "testing" "time" ) const site = "https://example.test" func nodesByType(t *testing.T, g map[string]any) map[string]map[string]any { t.Helper() if g["@context"] != Context { t.Fatalf("@context = %v", g["@context"]) } out := map[string]map[string]any{} for _, n := range Nodes(g) { out[NodeType(n)] = n } return out } func refID(v any) string { m, _ := v.(map[string]any) s, _ := m["@id"].(string) return s } // TestDefaultAndAllowedTypes pins the type table (UI-SPEC C17): the // default per kind and registry variant, the choices per kind, and an // editor's choice outside them falling back to the default. func TestDefaultAndAllowedTypes(t *testing.T) { for _, tc := range []struct{ kind, variant, want string }{ {KindArticle, "", TypeBlogPosting}, {KindCollection, "", TypeCollectionPage}, {KindProfile, "", TypeProfilePage}, {KindPage, "", TypeWebPage}, {"", "", TypeWebPage}, {KindPage, VariantOffer, TypeService}, {KindPage, VariantService, TypeService}, {KindPage, "landing", TypeWebPage}, {KindArticle, VariantOffer, TypeBlogPosting}, } { if got := DefaultType(tc.kind, tc.variant); got != tc.want { t.Errorf("DefaultType(%q, %q) = %q, want %q", tc.kind, tc.variant, got, tc.want) } } for _, tc := range []struct { kind, typ string want bool }{ {KindArticle, TypeNewsArticle, true}, {KindArticle, TypeService, false}, {KindCollection, TypeCollectionPage, true}, {KindCollection, TypeWebPage, false}, {KindProfile, TypeProfilePage, true}, {KindPage, TypeContactPage, true}, {KindPage, TypeBlogPosting, false}, {KindPage, "Product", false}, {KindArticle, "", true}, } { if got := Allowed(tc.kind, tc.typ); got != tc.want { t.Errorf("Allowed(%q, %q) = %v, want %v", tc.kind, tc.typ, got, tc.want) } } if EffectiveType(KindPage, "", TypeBlogPosting) != TypeWebPage || EffectiveType(KindPage, VariantOffer, "") != TypeService || EffectiveType(KindArticle, "", TypeArticle) != TypeArticle { t.Fatal("EffectiveType is wrong") } } // TestPageIDs pins P33-22's @id values: site-level ids on the origin, the // person publisher's own fragment, page-level ids on the page URL. func TestPageIDs(t *testing.T) { ids := PageIDs(site, site+"/blog/a", PublisherOrganization) if ids.WebSite != site+"/#website" || ids.Publisher != site+"/#organization" || ids.Logo != site+"/#logo" || ids.WebPage != site+"/blog/a#webpage" || ids.Breadcrumb != site+"/blog/a#breadcrumb" || ids.Article != site+"/blog/a#article" || ids.ItemList != site+"/blog/a#itemlist" || ids.Profile != site+"/blog/a#profile" || ids.Service != site+"/blog/a#service" { t.Fatalf("ids = %+v", ids) } if PageIDs(site, site+"/", PublisherPerson).Publisher != site+"/#person" { t.Fatal("a person publisher is /#person") } } // TestBuildArticle pins an article page's graph: WebPage, BlogPosting, // BreadcrumbList, VideoObject, WebSite and the publisher, linked by @id; // headline, authors with profile ids, images, dates (dateModified falling // back to datePublished), word count and section. func TestBuildArticle(t *testing.T) { at := time.Date(2026, 11, 1, 10, 0, 0, 0, time.FixedZone("CET", 3600)) words := 420 g, err := Build(context.Background(), Input{ Site: site, URL: site + "/blog/a", Lang: "pl", Kind: KindArticle, Name: "Grzyby", Description: "O grzybach", Images: []string{site + "/a.jpg", site + "/b.jpg"}, PublishedAt: &at, Words: &words, Section: "Lecznicze", Authors: []Person{{Name: "Ada", URL: site + "/autor/ada"}, {Name: " "}, {Name: "Gość"}}, Videos: []Video{{Provider: "YouTube", ID: "abc 1"}, {Provider: "vimeo", ID: "9"}, {Provider: "youtube", ID: ""}}, Crumbs: []Crumb{{Name: "Start", URL: site + "/"}, {Name: "Grzyby", URL: site + "/blog/a"}}, SiteName: "Acme", SearchURL: "/szukaj?q={search_term_string}", Publisher: Publisher{Type: PublisherOrganization, Logo: site + "/logo.png"}, }) if err != nil { t.Fatal(err) } by := nodesByType(t, g) for _, typ := range []string{TypeWebPage, TypeBlogPosting, TypeBreadcrumbList, TypeVideoObject, TypeWebSite, TypeOrganization} { if by[typ] == nil { t.Fatalf("graph lacks %s: %v", typ, g) } } if len(Nodes(g)) != 6 { t.Fatalf("%d nodes, want 6 (one video)", len(Nodes(g))) } page, art := by[TypeWebPage], by[TypeBlogPosting] if refID(page["isPartOf"]) != site+"/#website" || refID(page["breadcrumb"]) != site+"/blog/a#breadcrumb" || page["inLanguage"] != "pl" { t.Fatalf("page = %v", page) } if art["headline"] != "Grzyby" || refID(art["mainEntityOfPage"]) != site+"/blog/a#webpage" || refID(art["publisher"]) != site+"/#organization" || art["datePublished"] != "2026-11-01T09:00:00Z" || art["dateModified"] != "2026-11-01T09:00:00Z" || art["wordCount"] != 420 || art["articleSection"] != "Lecznicze" { t.Fatalf("article = %v", art) } authors := art["author"].([]any) if len(authors) != 2 || authors[0].(map[string]any)["@id"] != site+"/autor/ada#profile" || authors[1].(map[string]any)["url"] != nil { t.Fatalf("authors = %v", authors) } video := by[TypeVideoObject] if video["embedUrl"] != "https://www.youtube.com/embed/abc%201" || video["@id"] != site+"/blog/a#video-1" || video["uploadDate"] != "2026-11-01T09:00:00Z" || video["description"] != "O grzybach" { t.Fatalf("video = %v", video) } ws := by[TypeWebSite] action := ws["potentialAction"].(map[string]any) if ws["url"] != site+"/" || action["target"].(map[string]any)["urlTemplate"] != site+"/szukaj?q={search_term_string}" { t.Fatalf("website = %v", ws) } org := by[TypeOrganization] if org["name"] != "Acme" || refID(org["image"]) != site+"/#logo" { t.Fatalf("publisher = %v", org) } if _, err := json.Marshal(g); err != nil { t.Fatalf("graph does not marshal: %v", err) } } // TestBuildPerKind pins the page node and main entity per kind: a // collection's ItemList, a profile's Person, a service's offer with the // normalised price, a contact page about the publisher, the home page // about the publisher, and no BreadcrumbList for a single crumb. func TestBuildPerKind(t *testing.T) { build := func(in Input) map[string]map[string]any { in.Site, in.SiteName = site, "Acme" if in.URL == "" { in.URL = site + "/x" } g, err := Build(context.Background(), in) if err != nil { t.Fatal(err) } return nodesByType(t, g) } col := build(Input{Kind: KindCollection, Name: "Lecznicze"}) if refID(col[TypeCollectionPage]["mainEntity"]) != site+"/x#itemlist" { t.Fatalf("collection = %v", col) } prof := build(Input{Kind: KindProfile, Name: "Ada page", Profile: &Person{Name: "Ada"}, Images: []string{site + "/ada.jpg"}}) if refID(prof[TypeProfilePage]["mainEntity"]) != site+"/x#profile" || prof[TypePerson]["name"] != "Ada" || prof[TypePerson]["image"] != site+"/ada.jpg" { t.Fatalf("profile = %v", prof) } svc := build(Input{Kind: KindPage, Variant: VariantOffer, Name: "Audyt", Service: ServiceFields{ServiceType: "SEO", AreaServed: "Kraków, Warszawa", Price: "149,99", Currency: "pln"}}) offer := svc[TypeService]["offers"].(map[string]any) if refID(svc[TypeWebPage]["mainEntity"]) != site+"/x#service" || offer["price"] != "149.99" || offer["priceCurrency"] != "PLN" || len(svc[TypeService]["areaServed"].([]any)) != 2 { t.Fatalf("service = %v", svc) } one := build(Input{Kind: KindPage, Variant: VariantService, Service: ServiceFields{AreaServed: " Kraków ", Price: "abc"}}) if one[TypeService]["areaServed"] != "Kraków" || one[TypeService]["offers"] != nil { t.Fatalf("service with one area and no price = %v", one[TypeService]) } contact := build(Input{Type: TypeContactPage, Crumbs: []Crumb{{Name: "Start", URL: site + "/"}}}) if refID(contact[TypeContactPage]["mainEntity"]) != site+"/#organization" || refID(contact[TypeContactPage]["about"]) != site+"/#organization" || contact[TypeBreadcrumbList] != nil { t.Fatalf("contact = %v", contact) } home := build(Input{URL: site + "/", Home: true}) if refID(home[TypeWebPage]["about"]) != site+"/#organization" { t.Fatalf("home = %v", home) } } // TestPublisherNode pins the publisher: named after the site when unnamed, // nil without any name, a person's image instead of a logo, and a local // business with its address, geo, hours (bad rows dropped) and price // range. func TestPublisherNode(t *testing.T) { if PublisherNode(site, Publisher{}, " ") != nil { t.Fatal("no name must give no node") } person := PublisherNode(site, Publisher{Type: PublisherPerson, Name: "Ada", Logo: site + "/ada.jpg", SameAs: []string{"https://x.test/ada"}}, "Acme") if person["@type"] != TypePerson || person["@id"] != site+"/#person" || person["logo"] != nil || person["image"].(map[string]any)["url"] != site+"/ada.jpg" || len(person["sameAs"].([]any)) != 1 { t.Fatalf("person = %v", person) } lat, lng := 50.06, 19.94 local := PublisherNode(site, Publisher{Name: "Sklep", Phone: "+48 1", Local: &Local{ Type: "Store", PriceRange: "$$", Street: "Rynek 1", City: "Kraków", Country: "pl", Latitude: &lat, Longitude: &lng, Hours: []Hours{{Day: "mon", Opens: "09:00", Closes: "17:00"}, {Day: "xyz", Opens: "1", Closes: "2"}, {Day: "tue", Opens: "09:00"}}, }}, "") addr := local["address"].(map[string]any) if local["@type"] != "Store" || addr["addressCountry"] != "PL" || addr["streetAddress"] != "Rynek 1" || local["geo"] == nil || len(local["openingHoursSpecification"].([]any)) != 1 || local["priceRange"] != "$$" || local["telephone"] != "+48 1" || local["url"] != site+"/" { t.Fatalf("local business = %v", local) } bare := PublisherNode(site, Publisher{Name: "Sklep", Local: &Local{}}, "") if bare["@type"] != TypeLocalBusiness || bare["address"] != nil { t.Fatalf("local business without data = %v", bare) } } // TestNormalizePrice pins the price forms an editor may type. func TestNormalizePrice(t *testing.T) { for in, want := range map[string]string{"150": "150", "149,99": "149.99", " 149.99 ": "149.99", "0": "0", "": "", "abc": "", "-5": "", "1e3": "", "+5": "", "12,5,0": ""} { if got := NormalizePrice(in); got != want { t.Errorf("NormalizePrice(%q) = %q, want %q", in, got, want) } } if strings.Join(splitList(" a, ,b ,"), "|") != "a|b" { t.Fatal("splitList is wrong") } }