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.
+
+ 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:
+
+
+
+
Expected
Meaning
Files
+
+
+ {{- range .Outcomes }}
+
{{ .Name }}
{{ .Meaning }}
{{ .Count }}
+ {{- end }}
+
+
+
+
+
+
+
What can you change?
+ {{- if .Settings }}
+
+
+
+
Setting
Takes
Default
What it does
+
+
+ {{- range .Settings }}
+
+
--{{ .Flag }}
+
{{ .Takes }}
+
{{ .Default }}
+
{{ .Detail }}{{ if .Placeholder }} This default is our placeholder, not your system's value. Pass your own.{{ end }}
+
+ {{- 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:
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.
+
+ 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:
+
+
+
+
Oczekiwanie
Znaczenie
Pliki
+
+
+ {{- range .Outcomes }}
+
{{ .Name }}
{{ .Meaning }}
{{ .Count }}
+ {{- end }}
+
+
+
+
+
+
+
Co można zmienić?
+ {{- if .Settings }}
+
+
+
+
Ustawienie
Przyjmuje
Domyślnie
Co robi
+
+
+ {{- range .Settings }}
+
+
--{{ .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 }}
+
+ {{- 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:
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 @@