Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@ because it turns other people's test suites red.

### Added

- **Films: the `webm` format.** A WebM video with an AV1 picture and no
sound, at the exact size you ask for, like every other format. Its length
is a setting of its own, independent of the size: an hour at thirty frames
a second fits in less than a megabyte (977 128 B at the smallest), because
a film that shows one picture costs a few bytes for every frame after the
first. Set `duration`,
`frame_rate` (whole rates from 1 to 60), `keyframe_interval` - how far
apart the frames a player can start from are - `width`, `height` and
`quality`. The picture is the gradient with the self describing label the
image formats draw, and it stays the same for the whole film. It can be up
to 4096 pixels wide and at most 4096x2304 pixels in all, the largest the
built in encoder writes correctly - a larger one is refused with a pair
that fits. The manifest
says what a test can check: `duration_ms`, `frame_count`, `frame_rate`,
`keyframe_count`, `width`, `height`, `compression: av1` and `audio: false`.
A length that does not end on a frame is refused with the two nearest that
do - at 30 frames a second lengths go in steps of 100ms. Below the
smallest film the settings allow, the refusal says how small it can be and
which settings make it smaller. AV1 plays in current browsers, and an older
player or a pipeline that expects H.264 may refuse it - which is a test
worth having. The window lists it under Video.
- **A length of time as a setting.** `duration` and `keyframe_interval` take
`10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number
is refused rather than read as seconds, and so is a length that does not
land on a whole millisecond.
- **The website in twenty-two languages.** Beside English and Polish it now
reads in Simplified and Traditional Chinese, Japanese, German, French,
Spanish, Brazilian Portuguese, Italian, Indonesian, Russian, Turkish, Czech,
Expand Down Expand Up @@ -103,6 +128,12 @@ because it turns other people's test suites red.

### Changed

- **`empty-and-minimal` now includes a film.** The preset builds the smallest
file of every format this build has, so with `formats` left at `all` it
gains a `webm` target, and the recipe `tfg preset eject empty-and-minimal`
prints is a target longer. A manifest from a run of that preset records the
hash of that recipe, so its `recipe_hash` differs from one written before.
No generated file changes, and a run that names its `formats` is untouched.
- **An empty text box in the window reads "not set"** instead of "worked out
from the size" when leaving it empty means going without - the password
of an archive, the author of a PDF. What that means for the file is in the
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

**Testing Files Generator** is a tool for QA engineers and developers who need real
files to test against - an upload form, a parser, anything that takes a file and
has an opinion about it. You pick one of its 26 formats and the size you want,
has an opinion about it. You pick one of its 27 formats and the size you want,
and you get **exactly that**: ask for a 10 MB PDF and you get a PDF that a reader
will open, at 10 MB to the byte. Every run also leaves a manifest saying **what
your system should do with each file**, which is the part other generators leave
Expand All @@ -23,7 +23,7 @@ needs it finds out it exists.

- **Hit an exact size, to the byte** - ask for 10485761 bytes and get exactly
that, never a silently rounded file.
- **Write 26 real formats** - a generated PNG opens in an image viewer, a DOCX
- **Write 27 real formats** - a generated PNG opens in an image viewer, a DOCX
opens in Word, a ZIP extracts. Not padded zeros with an extension.
- **Say what should happen to each file** - the manifest carries an expected
outcome, so your test reads the assertion instead of you writing it out.
Expand Down Expand Up @@ -156,8 +156,8 @@ logs matches ./logs/manifest.json: 10000 files checked

## 📁 Formats it generates

Twenty six, and every one is a **real file of that format** - it opens in the
software that owns it, at the exact size you asked for:
Every one is a **real file of that format** - it opens in the software that
owns it, at the exact size you asked for:

| group | formats |
|---|---|
Expand All @@ -167,6 +167,7 @@ software that owns it, at the exact size you asked for:
| ⚙️ **Configuration** | `yaml`, `toml` |
| 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) |
| 🔊 **Audio** | `wav` |
| 🎞️ **Video** | `webm` (AV1, no sound) |

Coming next: `7z`, `mp3`, `mp4`.

Expand Down Expand Up @@ -582,7 +583,7 @@ recipe cannot build on another file.

## 📁 Formats in detail

The twenty four formats are listed near the top of this file. Each is produced at an
The formats are listed near the top of this file. Each is produced at an
exact size and checked against independent readers before it ships - a PNG is
opened and its pixels compared, a DOCX is read back by three separate
libraries, an archive is extracted.
Expand All @@ -605,6 +606,7 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `avif`, `jpg`, `jxl` | `width`, `height`, `quality` |
| `ico` | `width`, `height`, `embed` |
| `wav` | `sample_rate`, `bit_depth`, `channels`, `content` |
| `webm` | `width`, `height`, `duration`, `frame_rate`, `keyframe_interval`, `quality` |
| `zip` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `password`, `encryption` |
| `targz` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `entry_mode`, `entry_owner` |
| `docx` | `paragraphs` |
Expand Down Expand Up @@ -833,7 +835,7 @@ a valid one of its format.

Honest scope, because a tool that oversells itself wastes your afternoon.

**Working end to end:** twenty four formats, recipes, presets, the desktop window,
**Working end to end:** every format above, recipes, presets, the desktop window,
`generate`, `validate`, `verify`, `cleanup`, boundary sets, archive contents,
size ranges, per format settings, manifests and every exit code above.

Expand Down
215 changes: 215 additions & 0 deletions internal/core/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package core

import (
"math"
"strconv"
"strings"
)

// ParseDuration turns a written length of time into an exact number of
// milliseconds.
//
// 10s -> 10 000
// 1m30s -> 90 000
// 1h -> 3 600 000
// 1.5s -> 1 500
// 500ms -> 500
// 1h 30m -> 5 400 000
//
// The units are h, m, s and ms, and case does not matter. A length written in
// several parts goes from the largest unit down, each unit once, so 1h30m and
// 1m30s read the way a clock does. A space between the parts is allowed,
// because that is how people write it by hand.
//
// A number with no unit is refused rather than read as seconds. Ten could be
// ten seconds or ten milliseconds, and the tool picking one is exactly the
// guess this type exists to remove - the same reason a size refuses 1e5 and
// 0x10. Sizes can take a bare number because bytes are the only thing a size
// counts, and a length of time has no such single unit.
//
// A length that does not land on a whole millisecond is refused rather than
// rounded, as a size that does not land on a whole byte is. A video or a sound
// built on a rounded length would carry a duration nobody asked for.
//
// The arithmetic is done on the digits rather than through a float, so 0.1s is
// a hundred milliseconds exactly and not whatever 0.1 times a thousand happens
// to come to.
func ParseDuration(s string) (int64, error) {
raw := strings.ToLower(strings.TrimSpace(s))
if raw == "" {
return 0, Refuse(Says("core.DurationEmpty", "an empty length of time: write one such as 10s, 1m30s or 500ms"))
}

var total int64
previous := -1
rest := raw
for rest != "" {
rest = strings.TrimLeft(rest, " ")
number, afterNumber := leadingDecimal(rest)
rest = strings.TrimLeft(afterNumber, " ")
unit, afterUnit := leadingLetters(rest)
rest = afterUnit

// A number followed by something that is neither a unit nor the end -
// 1,5s or -5s - is a spelling, not a missing unit, and is called one.
if number == "" || (unit == "" && rest != "") {
return 0, Refuse(Says("core.DurationNotALength",
"%q is not a length of time: write one such as 10s, 1m30s or 500ms. A sign, an exponent or a comma is refused rather than guessed at", A("Value", s)))
}
if unit == "" {
return 0, Refuse(Says("core.DurationNoUnit",
"%q has no unit: write 10s for seconds or 10ms for milliseconds, because a bare number could be either", A("Value", s)))
}
index, per, ok := durationUnit(unit)
if !ok {
return 0, Refuse(Says("core.DurationUnknownUnit",
"%q uses an unknown unit %q: use h, m, s or ms", A("Value", s), A("Unit", unit)))
}
if index <= previous {
return 0, Refuse(Says("core.DurationOutOfOrder",
"%q writes a unit twice or out of order: write the larger units first, each once, as 1h30m or 1m30s", A("Value", s)))
}
previous = index

part, err := wholeMilliseconds(s, number, per)
if err != nil {
return 0, err
}
if total > math.MaxInt64-part {
return 0, durationTooLong(s)
}
total += part
}
return total, nil
}

// FormatDuration writes a number of milliseconds the way ParseDuration reads
// it, in the shortest form a person would write: 10s, 1m30s, 1h, 500ms,
// 1.033s. ParseDuration of the result is the number again, which a guard holds
// for every value it tries.
func FormatDuration(ms int64) string {
if ms <= 0 {
return "0s"
}
hours := ms / 3_600_000
minutes := ms % 3_600_000 / 60_000
rest := ms % 60_000

var b strings.Builder
if hours > 0 {
b.WriteString(strconv.FormatInt(hours, 10) + "h")
}
if minutes > 0 {
b.WriteString(strconv.FormatInt(minutes, 10) + "m")
}
switch {
case rest == 0:
case rest%1000 == 0:
b.WriteString(strconv.FormatInt(rest/1000, 10) + "s")
case rest < 1000 && hours == 0 && minutes == 0:
b.WriteString(strconv.FormatInt(rest, 10) + "ms")
default:
fraction := strings.TrimRight(strconv.FormatInt(1000+rest%1000, 10)[1:], "0")
b.WriteString(strconv.FormatInt(rest/1000, 10) + "." + fraction + "s")
}
return b.String()
}

// durationUnit is where a unit stands among the four, largest first, and how
// many milliseconds one of it is.
func durationUnit(unit string) (index int, per int64, ok bool) {
switch unit {
case "h":
return 0, 3_600_000, true
case "m":
return 1, 60_000, true
case "s":
return 2, 1_000, true
case "ms":
return 3, 1, true
}
return 0, 0, false
}

// leadingDecimal splits off digits with at most one decimal point. Anything
// else - a sign, an exponent, a comma - ends the number, and the caller then
// meets it where a unit should be.
func leadingDecimal(s string) (number, rest string) {
i, dot := 0, false
for i < len(s) {
c := s[i]
if c >= '0' && c <= '9' {
i++
continue
}
if c == '.' && !dot {
dot = true
i++
continue
}
break
}
number = s[:i]
if strings.Trim(number, ".") == "" {
// A lone point is not a number, and saying so here keeps the message
// the one about numbers rather than one about units.
return "", s
}
return number, s[i:]
}

func leadingLetters(s string) (letters, rest string) {
i := 0
for i < len(s) && s[i] >= 'a' && s[i] <= 'z' {
i++
}
return s[:i], s[i:]
}

// wholeMilliseconds is one part of a length, worked out on the digits.
func wholeMilliseconds(s, number string, per int64) (int64, error) {
whole, fraction, _ := strings.Cut(number, ".")
fraction = strings.TrimRight(fraction, "0")

var n int64
if whole != "" {
w, err := strconv.ParseInt(whole, 10, 64)
if err != nil || w > math.MaxInt64/per {
return 0, durationTooLong(s)
}
n = w * per
}
if fraction == "" {
return n, nil
}

// Nine digits after the point is past anything a millisecond can come
// out of: the largest unit is an hour, 36 times ten to the fifth
// milliseconds, so a fraction whose last digit is not zero lands on a
// whole millisecond only within seven places.
notWhole := Refuse(Says("core.DurationNotWhole",
"%q does not land on a whole millisecond: write it to the millisecond, as 1.5s or 1500ms", A("Value", s)))
if len(fraction) > 9 {
return 0, notWhole
}
f, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, notWhole
}
scale := int64(1)
for range len(fraction) {
scale *= 10
}
if f*per%scale != 0 {
return 0, notWhole
}
part := f * per / scale
if n > math.MaxInt64-part {
return 0, durationTooLong(s)
}
return n + part, nil
}

func durationTooLong(s string) error {
return Refuse(Says("core.DurationTooLong", "%q is too long to count in milliseconds", A("Value", s)))
}
1 change: 1 addition & 0 deletions internal/format/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/tomlfile"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/txt"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/wav"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/webm"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/webp"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/xlsx"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/xmlfile"
Expand Down
Loading
Loading