diff --git a/internal/guard/site_test.go b/internal/guard/site_test.go index f2564178..ed5b126f 100644 --- a/internal/guard/site_test.go +++ b/internal/guard/site_test.go @@ -18,7 +18,6 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/cli" "github.com/donislawdev/TestingFilesGenerator/internal/format" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" - "github.com/donislawdev/TestingFilesGenerator/internal/preset" "github.com/donislawdev/TestingFilesGenerator/internal/site" "github.com/donislawdev/TestingFilesGenerator/internal/version" ) @@ -97,14 +96,7 @@ func factsFromTheProgram(t *testing.T) site.Facts { for _, d := range format.All() { props := make([]site.Property, 0, len(d.Properties)) for _, p := range d.Properties { - props = append(props, site.Property{ - Name: p.Name, - Kind: string(p.Kind), - Min: p.Min, - Max: p.Max, - Unit: p.Unit, - Choices: append([]string(nil), p.Choices...), - }) + props = append(props, siteProperty(p)) } formats = append(formats, site.Format{ ID: d.ID, @@ -125,16 +117,11 @@ func factsFromTheProgram(t *testing.T) site.Facts { }) } - ids := make([]string, 0, len(preset.All())) - for _, p := range preset.All() { - ids = append(ids, p.ID) - } - return site.Facts{ Version: version.Version, Formats: formats, ExitCodes: exitCodesInOrder(), - Presets: ids, + Presets: presetFactsFromTheProgram(t), Commands: commandsTheToolPrints(t), Downloads: declaredDownloads(), // Fixed on purpose. See the comment on the field. @@ -396,11 +383,9 @@ func TestEveryLanguageDescribesEverythingTheProgramCanProduce(t *testing.T) { t.Errorf("exit code %d has no meaning in %s, so that row of the table would be blank", code, lang.Code) } } - for _, id := range facts.Presets { - if _, ok := lang.Presets[id]; !ok { - t.Errorf("the preset %q has no question in %s", id, lang.Code) - } - } + // Presets are asked by TestEveryPresetIsDescribedInEveryLanguage, + // which holds far more of them than a question. + // // The other direction as well, which the rows above do not ask. A // command dropped from the program leaves its summary behind in both // language files, and the page would then be a list of what the tool @@ -449,6 +434,19 @@ func TestEveryLanguageDescribesEverythingTheProgramCanProduce(t *testing.T) { if _, ok := lang.Terms[p.Kind]; !ok { t.Errorf("%s.%s is a %q and %s has no word for that kind", f.ID, p.Name, p.Kind, lang.Code) } + // The shape is what the page shows instead of the kind, so + // its words are asked for the same way. In English they are + // the registry's own, since the page and the program + // describe one setting. + if p.Shape != "" { + said, ok := lang.Terms[p.Shape] + if !ok { + t.Errorf("%s.%s takes %q and %s has no words for that", f.ID, p.Name, p.Shape, lang.Code) + } + if ok && lang.Code == "en" && said != p.Shape { + t.Errorf("the registry says %s.%s takes %q and the English page says %q", f.ID, p.Name, p.Shape, said) + } + } if p.Unit != "" { if _, ok := lang.Terms[p.Unit]; !ok { t.Errorf("%s.%s counts %q and %s has no word for it, so the page would read as half translated", f.ID, p.Name, p.Unit, lang.Code) @@ -666,9 +664,24 @@ func TestTheSitemapNeedsNoSchemaButItsOwn(t *testing.T) { } } - pages := 0 + // Counted from what was rendered rather than from the language files. + // Since 2026-09-29 a page per preset is made from the registry, and those + // pages are in no language file - counting the files would compare the + // sitemap with a set that is missing them. So the count is every page + // published, and it is held to having reached the made pages at all: + // a count that silently stopped seeing them would agree with a sitemap + // that had also stopped naming them. + pages, written := 0, 0 + for path := range rendered { + if filepath.Base(path) == "index.html" { + pages++ + } + } for _, language := range s.Languages { - pages += len(language.Pages) + written += len(language.Pages) + } + if len(s.Facts.Presets) > 0 && pages <= written { + t.Fatalf("the site renders %d pages and its language files list %d, so the preset pages were not counted", pages, written) } if locations != pages { t.Errorf("the site has %d pages and the sitemap names %d of them, so a crawler reading it is told about the wrong set", pages, locations) diff --git a/internal/guard/sitepresets_test.go b/internal/guard/sitepresets_test.go new file mode 100644 index 00000000..5a89684b --- /dev/null +++ b/internal/guard/sitepresets_test.go @@ -0,0 +1,262 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "sync" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/manifest" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" + "github.com/donislawdev/TestingFilesGenerator/internal/site" +) + +// Every preset has a page of its own on the site since 2026-09-29, and what a +// page says about its preset is asked of the program here rather than written +// down beside it: the settings from the registry, what the set costs from +// tfg preset show, and how many files the system under test should take or +// turn away from a dry run of the set itself. The reasoning is in +// docs/PRESET-PAGES-2026-09-29.md. + +// presetAnswers is asked once per test binary. Every site guard renders the +// site, and asking six presets for a dry run each time would repeat the same +// second of work for the same answer. +var presetAnswers struct { + once sync.Once + facts []site.PresetFacts + err error +} + +// presetFactsFromTheProgram is every registered preset as the site shows it. +func presetFactsFromTheProgram(t *testing.T) []site.PresetFacts { + t.Helper() + presetAnswers.once.Do(func() { + presetAnswers.facts, presetAnswers.err = askEveryPreset() + }) + if presetAnswers.err != nil { + t.Fatalf("asking the program about its presets: %v", presetAnswers.err) + } + return presetAnswers.facts +} + +func askEveryPreset() ([]site.PresetFacts, error) { + // A dry run writes nothing, and it is still given a directory of its own + // to not write into, removed afterwards, in case that ever changes. + scratch, err := os.MkdirTemp("", "tfg-site-presets-") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(scratch) }() + + out := make([]site.PresetFacts, 0, len(preset.All())) + for _, p := range preset.All() { + facts := site.PresetFacts{ID: p.ID} + for _, param := range p.Parameters { + facts.Settings = append(facts.Settings, site.Setting{ + Property: siteProperty(param), + Default: param.Default, + Placeholder: p.SaidWhenDefaulted[param.Name] != "", + }) + } + for _, name := range p.Reads { + facts.Reads = append(facts.Reads, site.Read{Name: name, Default: p.ReadDefaults[name]}) + } + if facts.Budget, err = budgetOf(p.ID); err != nil { + return nil, err + } + if facts.Outcomes, err = outcomesOf(p.ID, filepath.Join(scratch, p.ID)); err != nil { + return nil, err + } + out = append(out, facts) + } + return out, nil +} + +// siteProperty is one setting flattened for the site, the same way for a +// format and for a preset - they are one type in the program, so the site +// describes them with one function. +func siteProperty(p format.Property) site.Property { + return site.Property{ + Name: p.Name, + Kind: string(p.Kind), + Min: p.Min, + Max: p.Max, + Unit: p.Unit, + Choices: append([]string(nil), p.Choices...), + Shape: p.Shape, + } +} + +// runQuietly runs one command in this process and hands back what it printed. +func runQuietly(args ...string) ([]byte, error) { + var stdout, stderr bytes.Buffer + if code := cli.Run(context.Background(), args, &stdout, &stderr); code != cli.ExitOK { + return nil, fmt.Errorf("tfg %v ended with %d:\n%s", args, code, stderr.String()) + } + return stdout.Bytes(), nil +} + +// budgetOf is what tfg preset show says a preset costs at its defaults. +func budgetOf(id string) (site.Budget, error) { + printed, err := runQuietly("preset", "show", id, "--json") + if err != nil { + return site.Budget{}, err + } + var shown struct { + Budget *struct { + Targets int `json:"targets"` + Files int `json:"files"` + TotalBytes int64 `json:"total_bytes"` + Formats []string `json:"formats"` + } `json:"budget"` + } + if err := json.Unmarshal(printed, &shown); err != nil { + return site.Budget{}, fmt.Errorf("reading what tfg preset show %s printed: %w", id, err) + } + // Refused rather than read as zero. A page announcing a set of no files + // because a field was renamed is the quiet kind of wrong this file is for. + if shown.Budget == nil || shown.Budget.Files == 0 { + return site.Budget{}, fmt.Errorf("tfg preset show %s --json printed no budget, so its page would say the set is empty", id) + } + b := shown.Budget + return site.Budget{Targets: b.Targets, Files: b.Files, Bytes: b.TotalBytes, Formats: b.Formats}, nil +} + +// outcomesOf counts the reactions a dry run of the preset declares, by name. +func outcomesOf(id, dir string) ([]site.Outcome, error) { + printed, err := runQuietly("generate", "--preset", id, "--dry-run", "--json", "--out", dir) + if err != nil { + return nil, err + } + var run struct { + Files []struct { + Expected struct { + Outcome string `json:"outcome"` + } `json:"expected"` + } `json:"files"` + } + if err := json.Unmarshal(printed, &run); err != nil { + return nil, fmt.Errorf("reading the dry run of %s: %w", id, err) + } + counts := map[string]int{} + for _, f := range run.Files { + counts[f.Expected.Outcome]++ + } + if len(counts) == 0 { + return nil, fmt.Errorf("a dry run of %s declared no files, so its page would have no reactions to show", id) + } + out := make([]site.Outcome, 0, len(counts)) + for name, n := range counts { + out = append(out, site.Outcome{Name: name, Count: n}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// TestEveryPresetIsDescribedInEveryLanguage asks for the words of every +// preset page, in both directions. +// +// The render already stops on a word that is missing, so this is about the +// two things it cannot see. The English words are copies of the registry, and +// a copy nobody compares is the defect this whole site exists to prevent: the +// questions were copies for a month before this and nothing held them. And the +// number of sentences - a Polish page with one catch fewer renders without a +// complaint and says less than the English one. +func TestEveryPresetIsDescribedInEveryLanguage(t *testing.T) { + langs := languagesOnDisk(t) + sawEnglish := false + for _, lang := range langs { + for _, p := range preset.All() { + text, ok := lang.Presets[p.ID] + if !ok { + t.Errorf("the preset %q has no words in %s", p.ID, lang.Code) + continue + } + describesItsPreset(t, lang, p, text) + } + for id := range lang.Presets { + if _, err := preset.Get(id); err != nil { + t.Errorf("%s describes a preset %q that the program does not register", lang.Code, id) + } + } + sawEnglish = sawEnglish || lang.Code == "en" + } + if !sawEnglish { + t.Fatal("no English language file was read, so nothing was compared with the registry") + } +} + +func describesItsPreset(t *testing.T, lang site.Language, p preset.Preset, text site.PresetText) { + t.Helper() + if text.Question == "" || text.Title == "" || text.PageTitle == "" || text.Description == "" { + t.Errorf("the preset %q is missing a question, title, page title or description in %s", p.ID, lang.Code) + } + if len(text.Catches) != len(p.Catches) { + t.Errorf("the registry says %d things %q catches and %s says %d", len(p.Catches), p.ID, lang.Code, len(text.Catches)) + } + declared := map[string]bool{} + for _, param := range p.Parameters { + declared[param.Name] = true + if _, ok := text.Details[param.Name]; !ok { + t.Errorf("--%s of %q has no sentence in %s", param.Name, p.ID, lang.Code) + } + if param.Shape != "" { + said, ok := lang.Terms[param.Shape] + if !ok { + t.Errorf("--%s of %q takes %q and %s has no words for that", param.Name, p.ID, param.Shape, lang.Code) + } + if ok && lang.Code == "en" && said != param.Shape { + t.Errorf("the registry says --%s of %q takes %q and the English page says %q", param.Name, p.ID, param.Shape, said) + } + } + } + for name := range text.Details { + if !declared[name] { + t.Errorf("%s describes a setting --%s that %q does not declare", lang.Code, name, p.ID) + } + } + if lang.Code != "en" { + return + } + if text.Question != p.Question || text.Title != p.Title { + t.Errorf("the registry calls %q %q and asks %q, and the English page says %q and %q", + p.ID, p.Title, p.Question, text.Title, text.Question) + } + if !slices.Equal(text.Catches, p.Catches) { + t.Errorf("the English page lists what %q catches differently from the registry:\n page: %q\n registry: %q", + p.ID, text.Catches, p.Catches) + } + for _, param := range p.Parameters { + if said := text.Details[param.Name]; said != param.Detail { + t.Errorf("the registry describes --%s of %q as %q and the English page says %q", param.Name, p.ID, param.Detail, said) + } + } +} + +// TestEveryReactionHasItsMeaningInEveryLanguage asks for the words of every +// outcome a manifest can declare, not only the ones today's presets happen to +// produce. A preset that starts declaring sanitize would otherwise stop the +// render on the day it lands, in a change about something else. +func TestEveryReactionHasItsMeaningInEveryLanguage(t *testing.T) { + outcomes := []string{manifest.OutcomeAccept, manifest.OutcomeReject, manifest.OutcomeSanitize, manifest.OutcomeUnspecified} + for _, lang := range languagesOnDisk(t) { + for _, o := range outcomes { + if lang.Outcomes[o] == "" { + t.Errorf("the outcome %q has no meaning in %s", o, lang.Code) + } + } + for o := range lang.Outcomes { + if !slices.Contains(outcomes, o) { + t.Errorf("%s explains an outcome %q that no manifest declares", lang.Code, o) + } + } + } +} diff --git a/internal/site/presets.go b/internal/site/presets.go new file mode 100644 index 00000000..704688b1 --- /dev/null +++ b/internal/site/presets.go @@ -0,0 +1,304 @@ +// This file gives every preset the program registers a page of its own, in +// every language, and holds what one of those pages is rendered against. +// +// These are the first pages of the site that come from a list rather than +// from site.json. The list is the registry, so a seventh preset gets its pages +// without anybody adding them - and without anybody writing its words, the +// render stops and names the preset and the language, which is the whole point +// of making the pages here rather than by hand. The reasoning, and what was +// left out on purpose, is in docs/PRESET-PAGES-2026-09-29.md. + +package site + +import ( + "fmt" + "path" + "regexp" + "strconv" + "strings" +) + +// PresetFacts is one preset as the program describes it. +// +// Everything here is a name or a number. The sentences about a preset differ +// by language and live in PresetText, for the same reason a unit does. +type PresetFacts struct { + ID string + Settings []Setting + Reads []Read + Budget Budget + Outcomes []Outcome +} + +// Setting is one parameter a preset declares. +type Setting struct { + Property + Default string + // Placeholder marks a default that stands in for a number only the + // system under test knows, such as the limit of an upload form. The + // program says so out loud when that default is used, and the page says + // it beside the value, so nobody copies our number and believes it is + // theirs. + Placeholder bool +} + +// Read is a flag of the tool itself that a preset gives a default to, rather +// than declaring a parameter of the same meaning. +type Read struct { + Name string + Default string +} + +// Budget is what the set costs at its defaults, as tfg preset show prints it. +type Budget struct { + Targets int + Files int + Bytes int64 + Formats []string +} + +// Outcome is how many files of the set a system should meet with one +// reaction, as the manifest of a dry run declares them. +type Outcome struct { + Name string + Count int +} + +// PresetText is every word one preset needs, in one language. +// +// Title is what the preset is called and PageTitle is the title of its page +// for a search engine, which has to say more than a name in the same few +// words. Details is keyed by the parameter name. Catches and Details in +// English are copies of the registry, held to it by +// TestEveryLanguageDescribesEverythingTheProgramCanProduce. +type PresetText struct { + Question string `json:"question"` + Title string `json:"title"` + PageTitle string `json:"pageTitle"` + Description string `json:"description"` + Catches []string `json:"catches"` + Details map[string]string `json:"details"` +} + +// presetParent is the key of the page every preset page sits under. +const presetParent = "presets" + +// presetKey pairs the pages of one preset across languages. +func presetKey(id string) string { return "preset/" + id } + +// addressable is what an id has to look like to become part of an address. +// +// The registry refuses only an empty id and a repeated one, and an id is +// written into a path on disk and a URL here. So this is the one place that +// asks, rather than trusting a rule nothing else holds. +var addressable = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// withPresetPages adds one page per preset to a language already filled in +// with the facts. +func (l Language) withPresetPages(f Facts) (Language, error) { + if len(f.Presets) == 0 { + return l, nil + } + parent, ok := find(l, presetParent) + if !ok { + return l, fmt.Errorf("the %s pages have no %q page, and every preset page sits under it", l.Code, presetParent) + } + pages := append([]Page(nil), l.Pages...) + for _, p := range f.Presets { + if !addressable.MatchString(p.ID) { + return l, fmt.Errorf("the preset %q cannot be part of an address - an id of lower case letters, digits and single dashes can", p.ID) + } + text, ok := l.Presets[p.ID] + if !ok || text.Title == "" || text.PageTitle == "" || text.Description == "" { + return l, fmt.Errorf("the preset %q has no title, page title or description written in %s", p.ID, l.Code) + } + pages = append(pages, Page{ + Key: presetKey(p.ID), + Slug: path.Join(parent.Slug, p.ID), + Title: text.PageTitle, + Description: text.Description, + Nav: text.Title, + Parent: presetParent, + Template: "preset", + Item: p.ID, + }) + } + l.Pages = pages + return l, nil +} + +// expanded runs every word of one preset through the facts. +func (t PresetText) expanded(through func(string) string) PresetText { + out := t + out.Question = through(t.Question) + out.Title = through(t.Title) + out.PageTitle = through(t.PageTitle) + out.Description = through(t.Description) + out.Catches = make([]string, len(t.Catches)) + for i, c := range t.Catches { + out.Catches[i] = through(c) + } + if t.Details != nil { + out.Details = make(map[string]string, len(t.Details)) + for k, v := range t.Details { + out.Details[k] = through(v) + } + } + return out +} + +// PresetList is every preset this build registers, described in the language +// being rendered, each with the address of its own page. +func (v view) PresetList() ([]Preset, error) { + out := make([]Preset, 0, len(v.Facts.Presets)) + for _, p := range v.Facts.Presets { + text, ok := v.Lang.Presets[p.ID] + if !ok || text.Question == "" { + return nil, fmt.Errorf("the preset %q has no question written in %s", p.ID, v.Lang.Code) + } + page, ok := find(v.Lang, presetKey(p.ID)) + if !ok { + return nil, fmt.Errorf("the preset %q has no %s page to link to", p.ID, v.Lang.Code) + } + out = append(out, Preset{ID: p.ID, Title: text.Title, Question: text.Question, URL: pageURL(v.Lang, page)}) + } + return out, nil +} + +// PresetPage is what the page of one preset shows. +type PresetPage struct { + ID string + Title string + Question string + Catches []string + Settings []SettingRow + Budget Budget + // Bytes is the total of the budget grouped in threes, the way tfg preset + // show prints it, so the two can be read side by side. + Bytes string + Outcomes []OutcomeRow +} + +// SettingRow is one line of the settings table. +type SettingRow struct { + Flag string + Takes string + Default string + Detail string + Placeholder bool +} + +// OutcomeRow is one line of the reactions table. +type OutcomeRow struct { + Name string + Meaning string + Count int +} + +// Placeholders are the settings whose default is ours rather than the +// reader's, which is what a command on the page has to spell out. +func (p PresetPage) Placeholders() []SettingRow { + var out []SettingRow + for _, s := range p.Settings { + if s.Placeholder { + out = append(out, s) + } + } + return out +} + +// Preset is the preset the page being rendered is about. +func (v view) Preset() (PresetPage, error) { + id := v.Page.Item + var facts *PresetFacts + for i := range v.Facts.Presets { + if v.Facts.Presets[i].ID == id { + facts = &v.Facts.Presets[i] + } + } + text, ok := v.Lang.Presets[id] + if facts == nil || !ok { + return PresetPage{}, fmt.Errorf("the %s page %q is about a preset %q that the program or the language does not know", v.Lang.Code, v.Page.Key, id) + } + settings, err := v.settingRows(*facts, text) + if err != nil { + return PresetPage{}, err + } + outcomes, err := v.outcomeRows(*facts) + if err != nil { + return PresetPage{}, err + } + return PresetPage{ + ID: id, + Title: text.Title, + Question: text.Question, + Catches: text.Catches, + Settings: settings, + Budget: facts.Budget, + Bytes: grouped(facts.Budget.Bytes), + Outcomes: outcomes, + }, nil +} + +// settingRows describes every setting of a preset, its own parameters first +// and then the flags of the tool it gives a default to. +// +// A flag of the tool has no sentence in the registry of presets, because it is +// the tool's rather than the preset's. So its words come from the word list, +// keyed by the flag, and a preset that starts reading a second flag stops the +// render until somebody writes them. +func (v view) settingRows(f PresetFacts, text PresetText) ([]SettingRow, error) { + out := make([]SettingRow, 0, len(f.Settings)+len(f.Reads)) + for _, s := range f.Settings { + takes, err := v.AllowedOf(s.Property) + if err != nil { + return nil, err + } + detail, ok := text.Details[s.Name] + if !ok { + return nil, fmt.Errorf("the setting --%s of the preset %q has no sentence written in %s", s.Name, f.ID, v.Lang.Code) + } + out = append(out, SettingRow{Flag: s.Name, Takes: takes, Default: s.Default, Detail: detail, Placeholder: s.Placeholder}) + } + for _, r := range f.Reads { + takes, err := v.Word("readTakes." + r.Name) + if err != nil { + return nil, err + } + detail, err := v.Word("read." + r.Name) + if err != nil { + return nil, err + } + out = append(out, SettingRow{Flag: r.Name, Takes: takes, Default: r.Default, Detail: detail}) + } + return out, nil +} + +// outcomeRows says what each reaction in the set means, in the language being +// rendered. +func (v view) outcomeRows(f PresetFacts) ([]OutcomeRow, error) { + out := make([]OutcomeRow, 0, len(f.Outcomes)) + for _, o := range f.Outcomes { + meaning, ok := v.Lang.Outcomes[o.Name] + if !ok { + return nil, fmt.Errorf("the outcome %q has no meaning written in %s", o.Name, v.Lang.Code) + } + out = append(out, OutcomeRow{Name: o.Name, Meaning: meaning, Count: o.Count}) + } + return out, nil +} + +// grouped writes a byte count in threes separated by spaces, as the command +// line does. A plain space rather than a narrow one, because the English +// pages are held to ASCII. +func grouped(n int64) string { + digits := strconv.FormatInt(n, 10) + var b strings.Builder + for i, d := range digits { + if i > 0 && (len(digits)-i)%3 == 0 { + b.WriteByte(' ') + } + b.WriteRune(d) + } + return b.String() +} diff --git a/internal/site/render.go b/internal/site/render.go index cb2fbfb9..c6262919 100644 --- a/internal/site/render.go +++ b/internal/site/render.go @@ -110,11 +110,37 @@ func (s Site) filledLanguages() ([]Language, error) { if err != nil { return nil, err } + // After expanding, so the page of a preset takes its title from words + // that are already filled in, and before anything is rendered, so the + // sitemap, the language links and the links between pages all see it. + if done, err = done.withPresetPages(s.Facts); err != nil { + return nil, err + } out[i] = done } return out, nil } +// navFor is the header as seen from one page. +// +// A page made from a list is not in the header, and there would be one link +// per preset in it if it were. The page it sits under is marked instead. +func navFor(lang Language, page Page) []NavItem { + var out []NavItem + for _, item := range lang.Pages { + if item.Parent != "" { + continue + } + out = append(out, NavItem{ + Label: item.Nav, + URL: pageURL(lang, item), + Current: item.Key == page.Key, + Section: page.Parent != "" && item.Key == page.Parent, + }) + } + return out +} + // copyExtras publishes files that live elsewhere in the repository. // // The window screenshot and the application icon come this way rather than @@ -141,13 +167,14 @@ func (s Site) viewFor(lang Language, page Page) (view, error) { Path: pageURL(lang, page), Canonical: s.Origin() + pageURL(lang, page), IsHome: page.Slug == "", + Nav: navFor(lang, page), } - for _, item := range lang.Pages { - v.Nav = append(v.Nav, NavItem{ - Label: item.Nav, - URL: pageURL(lang, item), - Current: item.Key == page.Key, - }) + if page.Parent != "" { + up, ok := find(lang, page.Parent) + if !ok { + return view{}, fmt.Errorf("the %s page %q sits under %q, which is not there", lang.Code, page.Key, page.Parent) + } + v.Up = &NavItem{Label: up.Nav, URL: pageURL(lang, up)} } for _, other := range s.Languages { mate, ok := find(other, page.Key) @@ -190,9 +217,7 @@ func (s Site) notFound(partials, shell string) ([]byte, error) { W: root.Words, Path: "/404.html", IsHome: false, - } - for _, item := range root.Pages { - v.Nav = append(v.Nav, NavItem{Label: item.Nav, URL: pageURL(root, item)}) + Nav: navFor(root, page), } const body = `

{{ .Word "notFoundTitle" }}

` + `

{{ .Word "notFoundLead" }}

` + @@ -223,7 +248,13 @@ func (s Site) renderPages(out map[string][]byte, partials, shell string) error { if err != nil { return err } - fragment, err := os.ReadFile(filepath.Join(s.ContentDir, lang.Code, page.Key+".html")) + // A page made from a list shares one content file with the rest + // of its list, and names it. Every other page has its own. + name := page.Key + if page.Template != "" { + name = page.Template + } + fragment, err := os.ReadFile(filepath.Join(s.ContentDir, lang.Code, name+".html")) if err != nil { return fmt.Errorf("reading the %s text of the %s page: %w", lang.Code, page.Key, err) } diff --git a/internal/site/site.go b/internal/site/site.go index 23b2a042..16adab40 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -40,6 +40,11 @@ type Property struct { Max int64 Unit string Choices []string + // Shape is what free text has to look like, as the registry words it. + // It is looked up among the terms like a unit, because the registry + // states it in English. Without it a text setting is described as "text", + // which says nothing about the value it wants. + Shape string } // Format is one entry of the registry, flattened for display. @@ -72,14 +77,17 @@ type Ending struct { Meaning string } -// Preset is one ready made set of files, named by the question it answers. +// Preset is one ready-made set of files, named by the question it answers, +// as a card that leads to its own page. // -// The identifier comes from the registry and the question from the language +// The identifier comes from the registry and the words from the language // file, for the same reason the units do: the registry states its question in // English, and a Polish page carrying it would be a page translated halfway. type Preset struct { ID string + Title string Question string + URL string } // Command is one command the tool offers, described in the language being @@ -123,7 +131,7 @@ type Facts struct { Version string Formats []Format ExitCodes []int - Presets []string + Presets []PresetFacts Downloads []Download // Commands is what tfg --help prints, in the order it prints it, read out @@ -196,6 +204,15 @@ type Page struct { Title string `json:"title"` Description string `json:"description"` Nav string `json:"nav"` + + // The three below are never written in site.json. They are set on the + // pages made from a list rather than by hand - one per preset - and say + // which page they sit under, which content file holds their text, and + // which item of the list they are about. A page with a parent is left out + // of the header, and the parent is marked there while it is open. + Parent string `json:"-"` + Template string `json:"-"` + Item string `json:"-"` } // Language is one whole version of the site. @@ -203,22 +220,24 @@ type Page struct { // Dir is the path prefix. It is empty for the language served at the root, // which is the one search engines are pointed at by x-default. // -// Endings, Terms, Presets and Commands are the places where a word has to -// exist for every value the program can produce, and a missing one is an error -// rather than a gap left in English. Endings is keyed by the exit code written -// out in decimal, Terms by the kind or unit exactly as the registry spells it, -// Presets by the identifier, and Commands by the name tfg --help prints. +// Endings, Terms, Presets, Commands and Outcomes are the places where a word +// has to exist for every value the program can produce, and a missing one is +// an error rather than a gap left in English. Endings is keyed by the exit code +// written out in decimal, Terms by the kind, unit or shape exactly as the +// registry spells it, Presets by the identifier, Commands by the name +// tfg --help prints, and Outcomes by the reaction a manifest declares. type Language struct { - Code string `json:"code"` - Name string `json:"name"` - Dir string `json:"dir"` - Words map[string]string `json:"words"` - Endings map[string]string `json:"endings"` - Terms map[string]string `json:"terms"` - Presets map[string]string `json:"presets"` - Commands map[string]string `json:"commands"` - Pages []Page `json:"pages"` - Faq []QA `json:"faq"` + Code string `json:"code"` + Name string `json:"name"` + Dir string `json:"dir"` + Words map[string]string `json:"words"` + Endings map[string]string `json:"endings"` + Terms map[string]string `json:"terms"` + Presets map[string]PresetText `json:"presets"` + Commands map[string]string `json:"commands"` + Outcomes map[string]string `json:"outcomes"` + Pages []Page `json:"pages"` + Faq []QA `json:"faq"` } // Site is everything needed to render. @@ -247,10 +266,16 @@ type Alternate struct { } // NavItem is one link in the header. +// +// Current is the page being read. Section is the page it sits under, which is +// marked as well so a reader on the page of one preset can see where they +// are - but told apart, because a screen reader announces "current page" for +// the first and would be wrong about the second. type NavItem struct { Label string URL string Current bool + Section bool } // Switch is the link to this page in another language. diff --git a/internal/site/view.go b/internal/site/view.go index 0b0215f4..d4fb75ec 100644 --- a/internal/site/view.go +++ b/internal/site/view.go @@ -68,8 +68,14 @@ func (l Language) expand(f Facts) (Language, error) { out.Words = everyValue(l.Words) out.Endings = everyValue(l.Endings) out.Terms = everyValue(l.Terms) - out.Presets = everyValue(l.Presets) out.Commands = everyValue(l.Commands) + out.Outcomes = everyValue(l.Outcomes) + if l.Presets != nil { + out.Presets = make(map[string]PresetText, len(l.Presets)) + for id, text := range l.Presets { + out.Presets[id] = text.expanded(through) + } + } out.Pages = make([]Page, len(l.Pages)) for i, p := range l.Pages { @@ -101,6 +107,9 @@ type view struct { Path string Body template.HTML IsHome bool + // Up is the page this one sits under, for the link back to it and the + // middle step of the breadcrumb. Nil for a page in the header. + Up *NavItem } // Word looks up a piece of interface text. @@ -137,20 +146,6 @@ func (v view) Endings() ([]Ending, error) { return out, nil } -// PresetList is every preset this build registers, described in the language -// being rendered. -func (v view) PresetList() ([]Preset, error) { - out := make([]Preset, 0, len(v.Facts.Presets)) - for _, id := range v.Facts.Presets { - question, ok := v.Lang.Presets[id] - if !ok { - return nil, fmt.Errorf("the preset %q has no question written in %s", id, v.Lang.Code) - } - out = append(out, Preset{ID: id, Question: question}) - } - return out, nil -} - // CommandList is every command tfg --help prints, in that order, summarised in // the language being rendered. // @@ -203,6 +198,11 @@ func (v view) AllowedOf(p Property) (string, error) { } return span + " " + unit, nil default: + // A shape says what free text has to look like, which the kind + // alone does not - "text" is no description of a list of sizes. + if p.Shape != "" { + return v.Term(p.Shape) + } return v.Term(p.Kind) } } diff --git a/web/assets/site.css b/web/assets/site.css index 3e009b64..c60dcaac 100644 --- a/web/assets/site.css +++ b/web/assets/site.css @@ -150,7 +150,7 @@ body { background: var(--surface-2); } -.mainnav a[aria-current="page"] { +.mainnav a[aria-current] { color: var(--text); background: var(--surface-2); } diff --git a/web/content/en/docs.html b/web/content/en/docs.html index ee60a4c6..30b6c027 100644 --- a/web/content/en/docs.html +++ b/web/content/en/docs.html @@ -217,9 +217,10 @@

What is in the manifest?

What is a preset?

- A ready made set of files that answers a common testing question, so you do not have to design the + A ready-made set of files that answers a common testing question, so you do not have to design the set yourself. Presets are ordinary recipes underneath, and eject prints the recipe so - you can edit it from there. + you can edit it from there. Each preset has a page of its own with what it + usually finds, what is in the set and every setting it takes.

{{ template "presetsList" . }}
tfg preset list
diff --git a/web/content/en/index.html b/web/content/en/index.html
index 52d613f9..ad991271 100644
--- a/web/content/en/index.html
+++ b/web/content/en/index.html
@@ -98,6 +98,18 @@ 

Other generators stop at the bytes. This one answers what your test actually

+
+

Presets

+

Pick the question, get the whole set

+

+ A preset is a set of test files designed around one testing question, so you do not have to work + out which files prove what. Each one has a page saying what it usually finds, what is in the set + and every setting it takes. +

+ {{ template "presetsList" . }} +

All presets, and how they relate to recipes

+
+

Quick start

Three commands to see it working

diff --git a/web/content/en/preset.html b/web/content/en/preset.html new file mode 100644 index 00000000..2ed60a90 --- /dev/null +++ b/web/content/en/preset.html @@ -0,0 +1,91 @@ +{{ with .Preset }} +

{{ $.Up.Label }}

+

{{ .Title }}

+

{{ .Question }}

+

+ The {{ .ID }} preset builds a whole set of real test files for this question in one + command, and a manifest.json beside them saying how your system should react to each + file. Everything below is read from the program, at the defaults of this version. +

+ +{{ if .Catches }} +
+

What does it usually find?

+
    + {{- range .Catches }} +
  • {{ . }}
  • + {{- end }} +
+
+{{ end }} + +
+

What is in the set?

+

At its defaults, as tfg preset show {{ .ID }} reports it:

+
+ + + + + + + +
Files{{ .Budget.Files }}
Targets in its recipe{{ .Budget.Targets }}
Total size{{ .Bytes }} B
Formats{{ join .Budget.Formats ", " }}
+
+

And what the manifest of that set expects from your system:

+
+ + + + + + {{- range .Outcomes }} + + {{- end }} + +
ExpectedMeaningFiles
{{ .Name }}{{ .Meaning }}{{ .Count }}
+
+
+ +
+

What can you change?

+ {{- if .Settings }} +
+ + + + + + {{- range .Settings }} + + + + + + + {{- end }} + +
SettingTakesDefaultWhat it does
--{{ .Flag }}{{ .Takes }}{{ .Default }}{{ .Detail }}{{ if .Placeholder }} This default is our placeholder, not your system's value. Pass your own.{{ end }}
+
+ {{- else }} +

This preset has no settings. The set is the same every time.

+ {{- end }} +
+ +
+

How do you run it?

+

See what the set would cost, build it, or take its recipe to edit:

+
tfg preset show {{ .ID }}
+tfg generate --preset {{ .ID }}{{ range .Placeholders }} --{{ .Flag }} {{ .Default }}{{ end }} --out ./{{ .ID }}
+tfg preset eject {{ .ID }} > {{ .ID }}.yaml
+

Or build on it in a recipe of your own, next to your tests:

+
version: 1
+extends: preset:{{ .ID }}
+{{- with .Placeholders }}
+with:
+{{- range . }}
+  {{ .Flag }}: {{ .Default }}
+{{- end }}
+{{- end }}
+
+{{ end }} diff --git a/web/content/en/presets.html b/web/content/en/presets.html new file mode 100644 index 00000000..5ebf9dfc --- /dev/null +++ b/web/content/en/presets.html @@ -0,0 +1,30 @@ +

Test file presets, one set for each testing question

+

+ A preset is a whole set of test files designed around one question, with a manifest saying how your + system should react to each file. You pick the question, the tool builds the set. Each preset has + its own page with what it usually finds, what is in the set and every setting it takes. +

+ +{{ template "presetsList" . }} + +
+

How is a preset different from a recipe?

+

+ Underneath, it is not. A preset is a recipe the tool writes for you from a few settings. + tfg preset eject prints that recipe so you can keep it next to your tests and edit it, + and a recipe of your own can build on a preset with one line, extends: preset: + followed by its id. +

+
tfg preset list
+tfg preset show size-boundaries
+tfg preset eject size-boundaries > my.yaml
+
+ +
+

Can I trust the defaults?

+

+ For the files, yes. For a number only your system knows, such as the limit of an upload form, a + default is a placeholder of ours, and the tool says so every time it uses one. The page of each + preset marks those settings, and tfg preset show says it before anything is written. +

+
diff --git a/web/content/en/site.json b/web/content/en/site.json index 3e52c382..8239b663 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -17,6 +17,13 @@ "title": "{{ .Facts.FormatCount }} Supported File Formats - PDF, DOCX, PNG, ZIP and More", "description": "Every file format this generator produces, the smallest file each one can be, and the settings each one accepts. All {{ .Facts.FormatCount }} open in the software that owns them." }, + { + "key": "presets", + "slug": "presets", + "nav": "Presets", + "title": "Test File Presets - Ready-Made Sets for QA Questions", + "description": "Ready-made sets of test files, each answering one testing question: upload limits, file names, encodings, table imports, empty files and upload validation." + }, { "key": "docs", "slug": "docs", @@ -80,7 +87,9 @@ "footerPrivacy": "This site loads no fonts, no scripts and no trackers from anywhere. It sets no cookies.", "notFoundTitle": "That page is not here", "notFoundLead": "The address you followed does not match any page on this site.", - "notFoundBack": "Go to the home page" + "notFoundBack": "Go to the home page", + "read.format": "The format of every file in the set. It is a flag of the tool itself, and the preset only gives it a default.", + "readTakes.format": "a format id from the formats page" }, "endings": { "0": "Everything worked.", @@ -96,12 +105,101 @@ "143": "Stopped by a signal, which is what a CI timeout looks like." }, "presets": { - "empty-and-minimal": "Does a file that is valid and as small as the format allows get through?", - "filename-handling": "Will my system store, show and give back a file name it did not expect?", - "size-boundaries": "Is a size limit enforced exactly where it is declared?", - "tabular-import": "Does my table import survive what real tools export?", - "text-encoding": "Does my reader know which encoding a file is in, or is it guessing?", - "upload-validation": "Does my upload form take what it should and turn the rest away?" + "empty-and-minimal": { + "question": "Does a file that is valid and as small as the format allows get through?", + "title": "Empty and minimal", + "pageTitle": "Smallest Valid and Empty Test Files in Every Format", + "description": "The smallest valid file this tool writes in each of its {{ .Facts.FormatCount }} formats, plus an empty file where the format allows one, each with the reaction to expect.", + "catches": [ + "a valid file turned away for being too small, where the check counts bytes instead of reading them", + "an empty file that brings the reader down rather than being reported", + "a picture one pixel wide that divides by zero on the way to a thumbnail", + "storage that reads nought bytes as a failed upload and keeps retrying" + ], + "details": { + "formats": "Which formats the set is built from. Leave it at all for every format this build has, or name the ones your system accepts." + } + }, + "filename-handling": { + "question": "Will my system store, show and give back a file name it did not expect?", + "title": "File name handling", + "pageTitle": "Problematic File Names for Testing - Unicode and Length", + "description": "Test files whose names break uploads and storage: other scripts and emoji, a right to left override, invisible characters, shell and SQL syntax, length limits.", + "catches": [ + "a name that looks like a different one on screen, in a log or in a list", + "a name cut, trimmed or rewritten between upload and storage", + "a length limit counted in characters where the storage counts bytes" + ], + "details": {} + }, + "size-boundaries": { + "question": "Is a size limit enforced exactly where it is declared?", + "title": "Size boundaries", + "pageTitle": "Test an Upload Size Limit - Files at the Exact Boundary", + "description": "Files one byte under, at and one byte over the size limit your system declares, plus wider steps either side, each marked with whether it should be accepted.", + "catches": [ + "off by one errors at the limit", + "MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass", + "a limit enforced in the browser and not on the server" + ], + "details": { + "limit": "The size limit your system declares. Everything else is measured from it.", + "spread": "How far either side of the limit to reach, as a list of sizes." + } + }, + "tabular-import": { + "question": "Does my table import survive what real tools export?", + "title": "Tabular import", + "pageTitle": "CSV and Excel Import Test Files - Delimiters, Headers", + "description": "CSV files with other delimiters, CR LF endings, no header and other quoting, a table wider than a spreadsheet shows, an Excel workbook and JSON in several layouts.", + "catches": [ + "a semicolon file read as one column, because the delimiter was assumed rather than looked for", + "a CRLF file split into rows with an empty row after each one", + "a headerless table whose first row of data is eaten as column names", + "an import that keeps the columns it can show and drops the rest without a word", + "a reader that takes JSON records one line at a time and stops at the first indented document" + ], + "details": { + "rows": "How many rows the spreadsheet holds. It is written at exactly the size that many rows package to, so the budget above moves with this.", + "columns": "How many columns each row of the spreadsheet has. Rows times columns has a ceiling, and asking past it is refused before anything is written." + } + }, + "text-encoding": { + "question": "Does my reader know which encoding a file is in, or is it guessing?", + "title": "Text encoding", + "pageTitle": "Text Encoding Test Files - UTF-8, UTF-16, BOM, CRLF", + "description": "The same text in UTF-8, UTF-16LE and UTF-16BE, with and without a byte order mark, and files ending lines in CR LF and LF, to test how a reader decodes text.", + "catches": [ + "a reader that assumes UTF-8 and shows a UTF-16 file as one character in three, or as rows of boxes", + "a byte order mark read as content, so the first field of an import starts with three stray characters", + "an importer that guesses the encoding from the opening bytes and guesses differently for a longer file", + "a CRLF file split into rows with an empty row after each one, or a carriage return kept inside the last field" + ], + "details": { + "sample": "How big each file of the set is. UTF-16 stores two bytes for every character, so an odd number is refused." + } + }, + "upload-validation": { + "question": "Does my upload form take what it should and turn the rest away?", + "title": "Upload validation", + "pageTitle": "Upload Validation Test Files - Type, Size and Name", + "description": "Files for testing an upload form: allowed and denied types, content that does not match its extension, the size limit either side, hostile names and a bulk upload.", + "catches": [ + "a limit enforced in the browser and not on the server", + "an SVG or an HTML file taken for a picture or for plain text, which is a way to get a script past a form", + "a file checked by its extension and never opened, so a PDF named .jpg goes through", + "a form that reads the whole body into memory before it looks at how big it is", + "an upload named PHOTO.JPG turned away where photo.jpg is taken, or the other way round", + "a name with spaces, brackets or characters outside ASCII written to disk unchanged" + ], + "details": { + "limit": "The size limit your upload form declares. This set takes one step either side of it - for a file at every distance, run the size-boundaries preset.", + "allow": "Which types your form is supposed to accept. Each one becomes a real file of that type, and they are the positive control of the whole set.", + "deny": "Which extensions your form is supposed to turn away. An extension this build has no format for still gets a file under that name, holding plain text.", + "far-over": "How far past the limit the one big file goes. Turn it off where writing several times the limit is not worth the disk.", + "bulk": "How many files the mass upload holds. Nought leaves that group out of the set altogether." + } + } }, "commands": { "generate": "produce files, from a recipe or from flags", @@ -115,6 +213,12 @@ "version": "print the tool version", "license": "print the licence and what it means for generated files" }, + "outcomes": { + "accept": "Your system should take the file.", + "reject": "Your system should turn the file away.", + "sanitize": "Your system should take the file and clean it, for example by renaming it.", + "unspecified": "It depends on the rules of your system. You decide, then check that what happens is what you meant." + }, "terms": { "oracleNone": "not applicable", "int": "any whole number", @@ -130,7 +234,14 @@ "hertz": "hertz", "megapixels": "megapixels", "million cells": "million cells", - "entries per second": "entries per second" + "entries per second": "entries per second", + "files": "files", + "sizes separated by commas": "sizes separated by commas", + "format ids separated by commas": "format ids separated by commas", + "format ids separated by commas, or all": "format ids separated by commas, or all", + "extensions separated by commas": "extensions separated by commas", + "the id of a format, as tfg formats lists them": "the id of a format, as tfg formats lists them", + "the password, in plain text": "the password, in plain text" }, "faq": [ { diff --git a/web/content/pl/docs.html b/web/content/pl/docs.html index 27363efb..bfa728a9 100644 --- a/web/content/pl/docs.html +++ b/web/content/pl/docs.html @@ -220,7 +220,9 @@

Czym jest preset?

To gotowy zestaw plików odpowiadający na częste pytanie testowe, żebyś nie musiał projektować zestawu samodzielnie. Presety są pod spodem zwykłymi przepisami, a eject wypisuje ten - przepis, więc możesz go od tego miejsca edytować. + przepis, więc możesz go od tego miejsca edytować. Każdy preset ma + własną stronę: co zwykle znajduje, co jest w zestawie i jakie ustawienia + przyjmuje.

{{ template "presetsList" . }}
tfg preset list
diff --git a/web/content/pl/index.html b/web/content/pl/index.html
index 61fadb10..9ec33daf 100644
--- a/web/content/pl/index.html
+++ b/web/content/pl/index.html
@@ -98,6 +98,18 @@ 

Inne generatory kończą na bajtach. Ten odpowiada na pytanie, które napraw

+
+

Presety

+

Wybierz pytanie, dostań cały zestaw

+

+ Preset to zestaw plików testowych zaprojektowany wokół jednego pytania testowego, więc nie musisz + sam ustalać, który plik czego dowodzi. Każdy ma stronę, która mówi, co zwykle znajduje, co jest + w zestawie i jakie ustawienia przyjmuje. +

+ {{ template "presetsList" . }} +

Wszystkie presety i to, jak mają się do przepisów

+
+

Szybki start

Trzy polecenia, żeby zobaczyć, jak to działa

diff --git a/web/content/pl/preset.html b/web/content/pl/preset.html new file mode 100644 index 00000000..784901aa --- /dev/null +++ b/web/content/pl/preset.html @@ -0,0 +1,91 @@ +{{ with .Preset }} +

{{ $.Up.Label }}

+

{{ .Title }}

+

{{ .Question }}

+

+ Preset {{ .ID }} buduje jednym poleceniem cały zestaw prawdziwych plików testowych do + tego pytania, a obok nich manifest.json, który mówi, jak system ma zareagować na każdy + plik. Wszystko poniżej pochodzi z programu, przy wartościach domyślnych tej wersji. +

+ +{{ if .Catches }} +
+

Co zwykle znajduje?

+
    + {{- range .Catches }} +
  • {{ . }}
  • + {{- end }} +
+
+{{ end }} + +
+

Co jest w zestawie?

+

Przy wartościach domyślnych, tak jak podaje to tfg preset show {{ .ID }}:

+
+ + + + + + + +
Pliki{{ .Budget.Files }}
Cele w przepisie{{ .Budget.Targets }}
Łączny rozmiar{{ .Bytes }} B
Formaty{{ join .Budget.Formats ", " }}
+
+

I czego manifest tego zestawu oczekuje od Twojego systemu:

+
+ + + + + + {{- range .Outcomes }} + + {{- end }} + +
OczekiwanieZnaczeniePliki
{{ .Name }}{{ .Meaning }}{{ .Count }}
+
+
+ +
+

Co można zmienić?

+ {{- if .Settings }} +
+ + + + + + {{- range .Settings }} + + + + + + + {{- end }} + +
UstawieniePrzyjmujeDomyślnieCo robi
--{{ .Flag }}{{ .Takes }}{{ .Default }}{{ .Detail }}{{ if .Placeholder }} Ta wartość domyślna jest naszą wartością zastępczą, a nie wartością Twojego systemu. Podaj własną.{{ end }}
+
+ {{- else }} +

Ten preset nie ma ustawień. Zestaw jest za każdym razem taki sam.

+ {{- end }} +
+ +
+

Jak go uruchomić?

+

Sprawdź, ile zestaw będzie kosztował, zbuduj go albo weź jego przepis do edycji:

+
tfg preset show {{ .ID }}
+tfg generate --preset {{ .ID }}{{ range .Placeholders }} --{{ .Flag }} {{ .Default }}{{ end }} --out ./{{ .ID }}
+tfg preset eject {{ .ID }} > {{ .ID }}.yaml
+

Albo zbuduj na nim własny przepis, trzymany obok testów:

+
version: 1
+extends: preset:{{ .ID }}
+{{- with .Placeholders }}
+with:
+{{- range . }}
+  {{ .Flag }}: {{ .Default }}
+{{- end }}
+{{- end }}
+
+{{ end }} diff --git a/web/content/pl/presets.html b/web/content/pl/presets.html new file mode 100644 index 00000000..dab2ec52 --- /dev/null +++ b/web/content/pl/presets.html @@ -0,0 +1,31 @@ +

Presety plików testowych, jeden zestaw na każde pytanie testowe

+

+ Preset to cały zestaw plików testowych zaprojektowany wokół jednego pytania, z manifestem, który + mówi, jak system ma zareagować na każdy plik. Ty wybierasz pytanie, narzędzie buduje zestaw. Każdy + preset ma własną stronę: co zwykle znajduje, co jest w zestawie i jakie ustawienia przyjmuje. +

+ +{{ template "presetsList" . }} + +
+

Czym preset różni się od przepisu?

+

+ Pod spodem niczym. Preset to przepis, który narzędzie pisze za Ciebie z kilku ustawień. + tfg preset eject wypisuje ten przepis, więc możesz go trzymać obok testów i edytować, + a własny przepis może zbudować na presecie jedną linią, extends: preset: i jego + identyfikator. +

+
tfg preset list
+tfg preset show size-boundaries
+tfg preset eject size-boundaries > my.yaml
+
+ +
+

Czy mogę ufać wartościom domyślnym?

+

+ Co do plików - tak. Co do liczby, którą zna tylko Twój system, na przykład limitu formularza + przesyłania, wartość domyślna jest naszą wartością zastępczą, a narzędzie mówi to za każdym razem, + gdy jej używa. Strona każdego presetu oznacza takie ustawienia, a tfg preset show mówi + o tym, zanim cokolwiek zostanie zapisane. +

+
diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 98a099ff..375eecee 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -17,6 +17,13 @@ "title": "{{ .Facts.FormatCount }} formatów plików testowych - PDF, DOCX, PNG, ZIP", "description": "Wszystkie formaty, jakie generuje to narzędzie, najmniejszy możliwy plik każdego z nich i ustawienia, które przyjmuje. Każdy otwiera się w swoim programie." }, + { + "key": "presets", + "slug": "presety", + "nav": "Presety", + "title": "Presety plików testowych - gotowe zestawy do testów QA", + "description": "Gotowe zestawy plików testowych, każdy odpowiada na jedno pytanie: limity uploadu, nazwy plików, kodowanie, import tabel, pliki puste i walidacja uploadu." + }, { "key": "docs", "slug": "dokumentacja", @@ -80,7 +87,9 @@ "footerPrivacy": "Ta strona nie ładuje żadnych fontów, skryptów ani liczników z zewnątrz. Nie ustawia ciasteczek.", "notFoundTitle": "Tej strony tu nie ma", "notFoundLead": "Adres, którym tu trafiłeś, nie pasuje do żadnej strony w tym serwisie.", - "notFoundBack": "Wróć na stronę główną" + "notFoundBack": "Wróć na stronę główną", + "read.format": "Format każdego pliku w zestawie. To flaga samego narzędzia, a preset daje jej tylko wartość domyślną.", + "readTakes.format": "identyfikator formatu ze strony formatów" }, "endings": { "0": "Wszystko się udało.", @@ -96,12 +105,101 @@ "143": "Zatrzymane sygnałem - tak wygląda przekroczony czas w CI." }, "presets": { - "empty-and-minimal": "Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?", - "filename-handling": "Czy mój system zapisze, pokaże i odda nazwę pliku, której się nie spodziewał?", - "size-boundaries": "Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?", - "tabular-import": "Czy import tabeli poradzi sobie z tym, co eksportują prawdziwe narzędzia?", - "text-encoding": "Czy mój czytnik wie, w jakim kodowaniu jest plik, czy zgaduje?", - "upload-validation": "Czy mój formularz przesyłania plików przyjmuje to, co powinien, i odrzuca resztę?" + "empty-and-minimal": { + "question": "Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?", + "title": "Pliki puste i minimalne", + "pageTitle": "Najmniejsze poprawne i puste pliki w każdym formacie", + "description": "Najmniejszy poprawny plik, jaki to narzędzie zapisze w każdym z {{ .Facts.FormatCount }} formatów, i plik pusty tam, gdzie format na to pozwala, każdy z oczekiwaną reakcją.", + "catches": [ + "poprawny plik odrzucony jako za mały, bo kontrola liczy bajty, zamiast go przeczytać", + "pusty plik, który wywraca czytnik, zamiast zostać zgłoszony", + "obrazek szeroki na jeden piksel, który w drodze do miniatury dzieli przez zero", + "magazyn, który zero bajtów uznaje za nieudane przesłanie i ponawia je bez końca" + ], + "details": { + "formats": "Z jakich formatów powstaje zestaw. Zostaw all, żeby dostać każdy format tej wersji, albo wymień te, które przyjmuje Twój system." + } + }, + "filename-handling": { + "question": "Czy mój system zapisze, pokaże i odda nazwę pliku, której się nie spodziewał?", + "title": "Obsługa nazw plików", + "pageTitle": "Problematyczne nazwy plików do testów - Unicode i długość", + "description": "Pliki z nazwami, które psują przesyłanie i zapis: inne alfabety i emoji, odwrócony kierunek tekstu, znaki niewidoczne, składnia powłoki i SQL, limity długości.", + "catches": [ + "nazwa, która na ekranie, w logu albo na liście wygląda jak inna", + "nazwa ucięta, przycięta albo przepisana między przesłaniem a zapisem", + "limit długości liczony w znakach tam, gdzie magazyn liczy bajty" + ], + "details": {} + }, + "size-boundaries": { + "question": "Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?", + "title": "Granice rozmiaru", + "pageTitle": "Test limitu rozmiaru pliku - pliki dokładnie na granicy", + "description": "Pliki o bajt mniejsze od limitu rozmiaru, równe mu i o bajt większe, do tego szersze kroki w obie strony, każdy z informacją, czy system ma go przyjąć.", + "catches": [ + "błędy o jeden na samym limicie", + "pomylone MB i MiB - to 4,8 procent, wystarczy, żeby przepuścić plik, który nie powinien przejść", + "limit sprawdzany w przeglądarce, a nie na serwerze" + ], + "details": { + "limit": "Limit rozmiaru, który deklaruje Twój system. Wszystko inne jest liczone od niego.", + "spread": "Jak daleko w obie strony od limitu sięgać, jako lista rozmiarów." + } + }, + "tabular-import": { + "question": "Czy import tabeli poradzi sobie z tym, co eksportują prawdziwe narzędzia?", + "title": "Import tabel", + "pageTitle": "Pliki testowe do importu CSV i Excel - separatory, nagłówki", + "description": "Pliki CSV z innymi separatorami, końcami linii CR LF, bez nagłówka i z innym cytowaniem, tabela szersza, niż pokaże arkusz, skoroszyt Excel i JSON w kilku układach.", + "catches": [ + "plik ze średnikami wczytany jako jedna kolumna, bo separator założono, zamiast go poszukać", + "plik z CRLF podzielony na wiersze z pustym wierszem po każdym", + "tabela bez nagłówka, której pierwszy wiersz danych zostaje zjedzony jako nazwy kolumn", + "import, który zostawia kolumny, jakie umie pokazać, a resztę bez słowa porzuca", + "czytnik, który bierze rekordy JSON po jednej linii i zatrzymuje się na pierwszym dokumencie z wcięciami" + ], + "details": { + "rows": "Ile wierszy ma arkusz. Plik ma dokładnie taki rozmiar, na jaki pakuje się tyle wierszy, więc budżet zestawu zmienia się razem z tą wartością.", + "columns": "Ile kolumn ma każdy wiersz arkusza. Iloczyn wierszy i kolumn ma sufit, a prośba ponad niego zostaje odrzucona, zanim cokolwiek powstanie." + } + }, + "text-encoding": { + "question": "Czy mój czytnik wie, w jakim kodowaniu jest plik, czy zgaduje?", + "title": "Kodowanie tekstu", + "pageTitle": "Pliki testowe kodowania - UTF-8, UTF-16, BOM, CRLF", + "description": "Ten sam tekst w UTF-8, UTF-16LE i UTF-16BE, ze znacznikiem kolejności bajtów i bez, oraz pliki z końcami linii CR LF i LF, do testu, jak czytnik dekoduje tekst.", + "catches": [ + "czytnik, który zakłada UTF-8 i pokazuje plik UTF-16 jako jeden znak na trzy albo jako rzędy prostokątów", + "znacznik kolejności bajtów wczytany jako treść, więc pierwsze pole importu zaczyna się od trzech obcych znaków", + "import, który zgaduje kodowanie z pierwszych bajtów i dla dłuższego pliku zgaduje inaczej", + "plik z CRLF podzielony na wiersze z pustym wierszem po każdym albo znak powrotu karetki zostawiony w ostatnim polu" + ], + "details": { + "sample": "Jak duży jest każdy plik zestawu. UTF-16 zapisuje dwa bajty na każdy znak, więc liczba nieparzysta zostaje odrzucona." + } + }, + "upload-validation": { + "question": "Czy mój formularz przesyłania plików przyjmuje to, co powinien, i odrzuca resztę?", + "title": "Walidacja uploadu", + "pageTitle": "Pliki do testu walidacji uploadu - typ, rozmiar, nazwa", + "description": "Pliki do testu formularza przesyłania: typy dozwolone i zakazane, treść niezgodna z rozszerzeniem, limit rozmiaru z obu stron, wrogie nazwy i przesyłanie masowe.", + "catches": [ + "limit sprawdzany w przeglądarce, a nie na serwerze", + "plik SVG albo HTML przyjęty jako obrazek albo zwykły tekst, co jest sposobem na przemycenie skryptu przez formularz", + "plik sprawdzany po rozszerzeniu i nigdy nieotwierany, więc PDF nazwany .jpg przechodzi", + "formularz, który wczytuje całe żądanie do pamięci, zanim sprawdzi, jak jest duże", + "plik PHOTO.JPG odrzucony tam, gdzie photo.jpg przechodzi, albo odwrotnie", + "nazwa ze spacjami, nawiasami albo znakami spoza ASCII zapisana na dysk bez zmian" + ], + "details": { + "limit": "Limit rozmiaru, który deklaruje Twój formularz. Ten zestaw robi jeden krok w każdą stronę od niego - po plik w każdej odległości sięgnij po preset size-boundaries.", + "allow": "Które typy formularz ma przyjmować. Każdy staje się prawdziwym plikiem tego typu i to one są kontrolą pozytywną całego zestawu.", + "deny": "Które rozszerzenia formularz ma odrzucać. Rozszerzenie, dla którego ta wersja nie ma formatu, i tak dostaje plik pod tą nazwą, ze zwykłym tekstem w środku.", + "far-over": "Jak daleko za limit sięga jeden duży plik. Wyłącz go tam, gdzie zapis kilkukrotności limitu nie jest wart miejsca na dysku.", + "bulk": "Ile plików ma przesyłanie masowe. Zero całkiem usuwa tę grupę z zestawu." + } + } }, "commands": { "generate": "tworzy pliki, z przepisu albo z flag", @@ -115,6 +213,12 @@ "version": "wypisuje wersję narzędzia", "license": "wypisuje licencję i to, co znaczy dla wygenerowanych plików" }, + "outcomes": { + "accept": "System powinien przyjąć plik.", + "reject": "System powinien odrzucić plik.", + "sanitize": "System powinien przyjąć plik i go oczyścić, na przykład zmieniając mu nazwę.", + "unspecified": "Zależy od reguł Twojego systemu. Ty decydujesz, a potem sprawdzasz, czy dzieje się to, co zamierzałeś." + }, "terms": { "oracleNone": "nie dotyczy", "int": "dowolna liczba całkowita", @@ -130,7 +234,14 @@ "hertz": "herców", "megapixels": "megapikseli", "million cells": "milionów komórek", - "entries per second": "wpisów na sekundę" + "entries per second": "wpisów na sekundę", + "files": "plików", + "sizes separated by commas": "rozmiary rozdzielone przecinkami", + "format ids separated by commas": "identyfikatory formatów rozdzielone przecinkami", + "format ids separated by commas, or all": "identyfikatory formatów rozdzielone przecinkami albo all", + "extensions separated by commas": "rozszerzenia rozdzielone przecinkami", + "the id of a format, as tfg formats lists them": "identyfikator formatu, tak jak wypisuje go tfg formats", + "the password, in plain text": "hasło, zwykłym tekstem" }, "faq": [ { diff --git a/web/public/404.html b/web/public/404.html index 469c1108..4f092c3c 100644 --- a/web/public/404.html +++ b/web/public/404.html @@ -39,6 +39,7 @@