- schema: types per kind, @id links, every node kind, the publisher and local business, every validator rule, custom JSON-LD caps and merge - crawl: HTML parse, each check's positive and negative case, fix routes with the fitting tool, the rendered comparison, allowRequest, resolverRules and NewRenderer - pagination, alternates and x-default, primary parents, the filter's addresses, registry paths and fields, auto-link addresses
215 lines
10 KiB
Go
215 lines
10 KiB
Go
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")
|
|
}
|
|
}
|