test: unit tests for the schema builder, crawler and page helpers
- 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
This commit is contained in:
148
classes/schema/custom_test.go
Normal file
148
classes/schema/custom_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func codeOf(err error) string {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestDecodeCustom pins the C17 caps: an object or a non-empty list of
|
||||
// objects, 16 KB, 8 levels, no <script in any case or spacing, the
|
||||
// parser's line and column for invalid JSON, nothing after the value, and
|
||||
// @context dropped.
|
||||
func TestDecodeCustom(t *testing.T) {
|
||||
nodes, err := DecodeCustom(` {"@context":"https://schema.org","@type":"FAQPage","n":1.50} `)
|
||||
if err != nil || len(nodes) != 1 || nodes[0]["@context"] != nil || nodes[0]["n"].(json.Number).String() != "1.50" {
|
||||
t.Fatalf("object = %v err=%v", nodes, err)
|
||||
}
|
||||
if nodes, err := DecodeCustom(`[{"@type":"A"},{"@type":"B"}]`); err != nil || len(nodes) != 2 {
|
||||
t.Fatalf("list = %v err=%v", nodes, err)
|
||||
}
|
||||
if nodes, err := DecodeCustom(" "); err != nil || nodes != nil {
|
||||
t.Fatalf("blank = %v err=%v", nodes, err)
|
||||
}
|
||||
deep := strings.Repeat(`{"a":`, 8) + "1" + strings.Repeat("}", 8)
|
||||
if _, err := DecodeCustom(deep); err != nil {
|
||||
t.Fatalf("8 levels must pass: %v", err)
|
||||
}
|
||||
for name, tc := range map[string]struct{ text, code string }{
|
||||
"too large": {`{"a":"` + strings.Repeat("x", MaxCustomBytes) + `"}`, CodeTooLarge},
|
||||
"too deep": {strings.Repeat(`{"a":`, 9) + "1" + strings.Repeat("}", 9), CodeTooDeep},
|
||||
"script": {`{"a":"</script><script>x"}`, CodeScriptTag},
|
||||
"script case": {`{"a":"< SCRIPT src=x>"}`, CodeScriptTag},
|
||||
"string": {`"x"`, CodeNotObject},
|
||||
"number": {`5`, CodeNotObject},
|
||||
"empty list": {`[]`, CodeNotObject},
|
||||
"mixed list": {`[{"a":1},2]`, CodeNotObject},
|
||||
"syntax": {"{\n \"a\": 1,\n \"b\" 2\n}", CodeInvalidJSON},
|
||||
"truncated": {`{"a":`, CodeInvalidJSON},
|
||||
"trailing": {`{"a":1} {"b":2}`, CodeInvalidJSON},
|
||||
} {
|
||||
if _, err := DecodeCustom(tc.text); codeOf(err) != tc.code {
|
||||
t.Errorf("%s: err %v, want code %s", name, err, tc.code)
|
||||
}
|
||||
}
|
||||
_, err = DecodeCustom("{\n \"a\": 1,\n \"b\" 2\n}")
|
||||
if e := err.(*Error); !strings.HasPrefix(e.Detail, "line 3, column ") {
|
||||
t.Fatalf("syntax detail = %q, want line 3", e.Detail)
|
||||
}
|
||||
if _, err := DecodeCustom(`{"a":`); !strings.Contains(err.Error(), "unexpected end of JSON input") {
|
||||
t.Fatalf("truncated = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalize pins the stored form of the seo_schema value: the type
|
||||
// checked against the kind, Service fields only for Service (price and
|
||||
// currency normalised and checked), custom JSON-LD re-serialised, and the
|
||||
// empty value stored as "".
|
||||
func TestNormalize(t *testing.T) {
|
||||
stored, v, err := Normalize(KindPage, VariantOffer, `{"type":"","fields":{"price":" 149,99 ","currency":"pln","areaServed":" Kraków "},"custom":"{\"@type\":\"FAQPage\"}"}`)
|
||||
if err != nil || v.Fields.Price != "149.99" || v.Fields.Currency != "PLN" || v.Fields.AreaServed != "Kraków" || !strings.Contains(v.Custom, "\n \"@type\": \"FAQPage\"\n") {
|
||||
t.Fatalf("service value = %+v err=%v", v, err)
|
||||
}
|
||||
var back Value
|
||||
if json.Unmarshal([]byte(stored), &back) != nil || back != v {
|
||||
t.Fatalf("stored = %s, want the normalised value", stored)
|
||||
}
|
||||
if _, v, _ := Normalize(KindArticle, "", `{"type":"Article","fields":{"price":"5"}}`); v.Fields != (ServiceFields{}) || v.Type != TypeArticle {
|
||||
t.Fatalf("an article keeps no Service fields: %+v", v)
|
||||
}
|
||||
if stored, _, err := Normalize(KindPage, "", `{"type":" ","fields":{"price":"5"}}`); err != nil || stored != "" {
|
||||
t.Fatalf("empty value = %q err=%v", stored, err)
|
||||
}
|
||||
if stored, _, err := Normalize(KindPage, "", ""); err != nil || stored != "" {
|
||||
t.Fatalf("blank = %q err=%v", stored, err)
|
||||
}
|
||||
if s, _, _ := Normalize(KindPage, "", `{"custom":"[{\"@type\":\"A\"},{\"@type\":\"B\"}]"}`); !strings.Contains(s, `[\n {`) {
|
||||
t.Fatalf("a list stays a list: %s", s)
|
||||
}
|
||||
for name, tc := range map[string]struct{ kind, variant, raw, code, field string }{
|
||||
"type": {KindPage, "", `{"type":"BlogPosting"}`, CodeTypeUnknown, ""},
|
||||
"price": {KindPage, VariantService, `{"fields":{"price":"abc"}}`, CodeFieldInvalid, "price"},
|
||||
"currency": {KindPage, VariantService, `{"fields":{"currency":"zloty"}}`, CodeFieldInvalid, "currency"},
|
||||
"json": {KindPage, "", `{"type":`, CodeInvalidJSON, ""},
|
||||
"custom": {KindPage, "", `{"custom":"<script>"}`, CodeScriptTag, ""},
|
||||
} {
|
||||
_, _, err := Normalize(tc.kind, tc.variant, tc.raw)
|
||||
var e *Error
|
||||
if !errors.As(err, &e) || e.Code != tc.code || e.Field != tc.field {
|
||||
t.Errorf("%s: err %v, want %s %s", name, err, tc.code, tc.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMerge pins the merge of custom nodes: a node with a generated @id is
|
||||
// merged into it (custom values win, the replaced properties reported
|
||||
// sorted), a node without @id or with a new @id is appended, and an equal
|
||||
// value is not reported.
|
||||
func TestMerge(t *testing.T) {
|
||||
g := map[string]any{"@context": Context, "@graph": []any{
|
||||
map[string]any{"@type": TypeWebPage, "@id": "p#webpage", "name": "Old", "url": "u"},
|
||||
map[string]any{"@type": TypeWebSite, "@id": "s#website", "name": "Site"},
|
||||
}}
|
||||
out, merged := Merge(g, []map[string]any{
|
||||
{"@id": "p#webpage", "name": "New", "url": "u", "about": "x", "description": "d"},
|
||||
{"@type": "FAQPage"},
|
||||
{"@id": "p#faq", "@type": "FAQPage"},
|
||||
{"@id": "s#website", "name": "Site"},
|
||||
})
|
||||
nodes := Nodes(out)
|
||||
if len(nodes) != 4 || nodes[0]["name"] != "New" || nodes[0]["description"] != "d" || nodes[2]["@type"] != "FAQPage" || nodes[3]["@id"] != "p#faq" {
|
||||
t.Fatalf("merged graph = %v", nodes)
|
||||
}
|
||||
if len(merged) != 1 || merged[0].ID != "p#webpage" || merged[0].Type != TypeWebPage || strings.Join(merged[0].Props, ",") != "name" {
|
||||
t.Fatalf("merges = %+v", merged)
|
||||
}
|
||||
if out["@context"] != Context {
|
||||
t.Fatal("the merged graph keeps its context")
|
||||
}
|
||||
_, none := Merge(g, nil)
|
||||
if none != nil {
|
||||
t.Fatalf("no custom nodes reported %v", none)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseValue pins the stored value's reading.
|
||||
func TestParseValue(t *testing.T) {
|
||||
if v, err := ParseValue(""); err != nil || v != (Value{}) {
|
||||
t.Fatal("blank is the zero value")
|
||||
}
|
||||
if _, err := ParseValue("{"); codeOf(err) != CodeInvalidJSON {
|
||||
t.Fatalf("invalid = %v", err)
|
||||
}
|
||||
if v, _ := ParseValue(`{"type":"Article","custom":"{}"}`); v.Type != "Article" || v.Custom != "{}" {
|
||||
t.Fatalf("value = %+v", v)
|
||||
}
|
||||
if (&Error{Code: "x"}).Error() != "schema: x" || (&Error{Code: "x", Detail: "d"}).Error() != "schema: x: d" {
|
||||
t.Fatal("Error() is wrong")
|
||||
}
|
||||
}
|
||||
214
classes/schema/graph_test.go
Normal file
214
classes/schema/graph_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
86
classes/schema/validate_test.go
Normal file
86
classes/schema/validate_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func findings(r Report) string {
|
||||
var out []string
|
||||
for _, f := range r.Findings {
|
||||
out = append(out, f.Level+":"+f.Property)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// TestValidateNodeRules pins every rule of the table: the required and
|
||||
// recommended properties per type, the LocalBusiness subtypes checked as
|
||||
// LocalBusiness, and a type without Google rules reported as Rules false
|
||||
// with no findings (RESEARCH Pitfall 11).
|
||||
func TestValidateNodeRules(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
node map[string]any
|
||||
rules bool
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"@type": TypeBlogPosting}, true, "recommended:author recommended:datePublished recommended:dateModified recommended:headline recommended:image"},
|
||||
{map[string]any{"@type": TypeArticle, "headline": "x", "author": []any{map[string]any{"name": "a"}}, "datePublished": "d", "dateModified": "d", "image": []any{"i"}}, true, ""},
|
||||
{map[string]any{"@type": TypeNewsArticle, "headline": " "}, true, "recommended:author recommended:datePublished recommended:dateModified recommended:headline recommended:image"},
|
||||
{map[string]any{"@type": TypeBreadcrumbList, "itemListElement": []any{}}, true, "required:itemListElement"},
|
||||
{map[string]any{"@type": TypeVideoObject, "name": "v"}, true, "required:thumbnailUrl required:uploadDate recommended:description recommended:embedUrl"},
|
||||
{map[string]any{"@type": TypeOrganization, "name": "o", "url": "u", "logo": map[string]any{"url": "l"}}, true, ""},
|
||||
{map[string]any{"@type": TypePerson}, true, "required:name recommended:url recommended:image"},
|
||||
{map[string]any{"@type": TypeWebSite, "url": "u"}, true, "required:name"},
|
||||
{map[string]any{"@type": TypeProfilePage, "mainEntity": map[string]any{}}, true, "required:mainEntity"},
|
||||
{map[string]any{"@type": "Store", "name": "s"}, true, "required:address recommended:telephone recommended:url recommended:geo recommended:openingHoursSpecification recommended:priceRange recommended:image"},
|
||||
{map[string]any{"@type": "FinancialService", "name": "s", "address": map[string]any{"a": 1}, "telephone": "1", "url": "u", "geo": map[string]any{"x": 1}, "openingHoursSpecification": []any{1}, "priceRange": "$", "image": "i"}, true, ""},
|
||||
{map[string]any{"@type": TypeWebPage}, false, ""},
|
||||
{map[string]any{"@type": TypeService}, false, ""},
|
||||
{map[string]any{"@type": "FAQPage"}, false, ""},
|
||||
{map[string]any{"@type": TypeCollectionPage}, false, ""},
|
||||
} {
|
||||
r := ValidateNode(tc.node)
|
||||
if r.Rules != tc.rules || findings(r) != tc.want || r.Findings == nil {
|
||||
t.Errorf("%v: rules=%v findings=%q, want rules=%v %q", tc.node["@type"], r.Rules, findings(r), tc.rules, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeType pins the first @type of a node in each JSON form.
|
||||
func TestNodeType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
node map[string]any
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"@type": "Article"}, "Article"},
|
||||
{map[string]any{"@type": []any{1, "Store", "LocalBusiness"}}, "Store"},
|
||||
{map[string]any{"@type": []string{"Person"}}, "Person"},
|
||||
{map[string]any{"@type": []string{}}, ""},
|
||||
{map[string]any{}, ""},
|
||||
{map[string]any{"@type": 5}, ""},
|
||||
} {
|
||||
if got := NodeType(tc.node); got != tc.want {
|
||||
t.Errorf("NodeType(%v) = %q, want %q", tc.node, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateGraph pins Validate over a graph: one report per node with
|
||||
// its @id, in order, and Nodes reading both graph forms and skipping
|
||||
// non-objects.
|
||||
func TestValidateGraph(t *testing.T) {
|
||||
g := map[string]any{"@graph": []any{map[string]any{"@type": TypeWebSite, "@id": "w", "name": "n", "url": "u"}, "junk", map[string]any{"@type": TypePerson, "@id": "p"}}}
|
||||
reps := Validate(g)
|
||||
if len(reps) != 2 || reps[0].ID != "w" || len(reps[0].Findings) != 0 || reps[1].ID != "p" || findings(reps[1]) != "required:name recommended:url recommended:image" {
|
||||
t.Fatalf("reports = %+v", reps)
|
||||
}
|
||||
if len(Nodes(map[string]any{"@graph": []map[string]any{{"@id": "a"}}})) != 1 || Nodes(map[string]any{}) != nil {
|
||||
t.Fatal("Nodes is wrong")
|
||||
}
|
||||
if got := Validate(map[string]any{}); got == nil || len(got) != 0 {
|
||||
t.Fatalf("empty graph = %v, want []", got)
|
||||
}
|
||||
if r, rec, ok := RulesFor("Unknown"); ok || r != nil || rec != nil {
|
||||
t.Fatal("an unknown type has no rules")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user