Check the log charge against every code point (closes #172)
check / check (push) Waiting to run
check / check (push) Waiting to run
TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit now covers all of Unicode, surrogates aside, in place of a dense range plus a sample. Below U+1000 each code point is checked on its own against both handlers, so the quote, backslash and control characters cannot hide behind neighbours charged more than the handlers write. From U+1000 up code points are logged 4,096 to a value, and each batch's summed charge must cover the bytes the real handler writes for it, measured as a line carrying the batch twice less one carrying it once. A failing batch is then measured one code point at a time, so the failure names how many are undercharged and the first of them. Model: opus-5-5
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -18,15 +19,11 @@ import (
|
|||||||
// width.
|
// width.
|
||||||
const budget = 64
|
const budget = 64
|
||||||
|
|
||||||
// sampleRunes is how many runes wide the values in the charge test
|
// batchRunes is how many consecutive code points the charge test logs
|
||||||
// are. The handlers add a constant per field — a pair of quotes when
|
// in one value from U+1000 up. Logging each of those on its own line
|
||||||
// the value needs quoting — so the per-rune charge is only visible
|
// is too slow for the suite under the race detector; 4,096 at a time
|
||||||
// once it is amortised over a run of them.
|
// is 271 batches, each logged on two lines, so 542 lines per handler.
|
||||||
const sampleRunes = 64
|
const batchRunes = 4096
|
||||||
|
|
||||||
// quotingSlack is that constant: the pair of quotes a handler adds to
|
|
||||||
// a value that needs them and omits from one that does not.
|
|
||||||
const quotingSlack = 2
|
|
||||||
|
|
||||||
// newHandlers are the two handlers internal/logger can install. Time
|
// newHandlers are the two handlers internal/logger can install. Time
|
||||||
// is dropped so a line's width is a function of its value alone —
|
// is dropped so a line's width is a function of its value alone —
|
||||||
@@ -66,46 +63,43 @@ func renderedWidth(
|
|||||||
return buf.Len()
|
return buf.Len()
|
||||||
}
|
}
|
||||||
|
|
||||||
// chargeTestRunes is the set of code points the charge test measures:
|
// emittedBytes is what a handler writes for the runes of s alone: the
|
||||||
// every rune in the first two planes' worth of the BMP that the
|
// width of a line carrying s twice, less that of a line carrying it
|
||||||
// handlers are most likely to treat specially, the separators that
|
// once. Both values hold the same runes, so the text handler quotes
|
||||||
// only slog's JSON handler escapes, and a stratified sample across
|
// both or neither, and the quotes cancel along with everything else on
|
||||||
// the rest of Unicode so the astral charge is exercised on more than
|
// the line.
|
||||||
// one hand-picked rune.
|
func emittedBytes(
|
||||||
func chargeTestRunes() []rune {
|
newHandler func(io.Writer) slog.Handler,
|
||||||
const (
|
s string,
|
||||||
denseCeiling = 0x800
|
) int {
|
||||||
stride = 1021
|
return renderedWidth(newHandler, s+s) - renderedWidth(newHandler, s)
|
||||||
surrogateLo = 0xD800
|
}
|
||||||
surrogateHi = 0xDFFF
|
|
||||||
)
|
|
||||||
|
|
||||||
var runes []rune
|
// firstUndercharged returns the first rune in s that the handler
|
||||||
|
// writes in more bytes than EncodedBytes charges for it, and how many
|
||||||
|
// runes in s are undercharged that way. It measures one rune per line,
|
||||||
|
// so the charge test calls it on the code points below U+1000, and from
|
||||||
|
// there up only on a batch that has already failed, to name the code
|
||||||
|
// points rather than just their range.
|
||||||
|
func firstUndercharged(
|
||||||
|
newHandler func(io.Writer) slog.Handler,
|
||||||
|
s string,
|
||||||
|
) (rune, int) {
|
||||||
|
first, count := rune(-1), 0
|
||||||
|
|
||||||
keep := func(r rune) {
|
for _, r := range s {
|
||||||
if r >= surrogateLo && r <= surrogateHi {
|
if emittedBytes(newHandler, string(r)) <= logfield.EncodedBytes(r) {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
runes = append(runes, r)
|
if count == 0 {
|
||||||
|
first = r
|
||||||
}
|
}
|
||||||
|
|
||||||
for r := range rune(denseCeiling) {
|
count++
|
||||||
keep(r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range []rune{
|
return first, count
|
||||||
0x2028, 0x2029, 0x200B, 0x4E00, 0xE000, 0xFFFD,
|
|
||||||
0x1000C, 0x1F600, 0xE0001, 0x10FFFF,
|
|
||||||
} {
|
|
||||||
keep(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
for r := rune(denseCeiling); r <= utf8.MaxRune; r += stride {
|
|
||||||
keep(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
return runes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
|
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
|
||||||
@@ -114,33 +108,90 @@ func chargeTestRunes() []rune {
|
|||||||
// how a stated ceiling becomes false without any test noticing, so
|
// how a stated ceiling becomes false without any test noticing, so
|
||||||
// the charge is measured against what the handlers actually write
|
// the charge is measured against what the handlers actually write
|
||||||
// rather than against the escaping rules as read.
|
// rather than against the escaping rules as read.
|
||||||
|
//
|
||||||
|
// Every code point below U+1000 is checked on its own, for both
|
||||||
|
// handlers. That range holds the quote, the backslash and the control
|
||||||
|
// characters the handlers escape, next to code points each handler
|
||||||
|
// writes in fewer bytes than their charge, which in a sum would cover
|
||||||
|
// a neighbour charged too little.
|
||||||
|
//
|
||||||
|
// From U+1000 up the text handler writes every code point in exactly
|
||||||
|
// its charge, so the rest of Unicode is checked batchRunes at a time:
|
||||||
|
// each batch's summed charge must cover what the handler writes for
|
||||||
|
// the whole batch. The sums there can miss the JSON handler alone
|
||||||
|
// writing one code point in more bytes than its charge, when it writes
|
||||||
|
// others in the same batch in fewer.
|
||||||
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
|
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
var below strings.Builder
|
||||||
|
for r := range rune(0x1000) {
|
||||||
|
below.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches []string
|
||||||
|
|
||||||
|
for lo := rune(0x1000); lo <= utf8.MaxRune; lo += batchRunes {
|
||||||
|
var batch strings.Builder
|
||||||
|
|
||||||
|
for r := lo; r < lo+batchRunes; r++ {
|
||||||
|
// Surrogate halves are not runes a string can carry.
|
||||||
|
if utf8.ValidRune(r) {
|
||||||
|
batch.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batches = append(batches, batch.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// What EncodedBytes charges for each batch. Under -race -cover this
|
||||||
|
// takes longer than logging the batches, so it is worked out once,
|
||||||
|
// by whichever handler finishes logging first, while the other is
|
||||||
|
// still logging.
|
||||||
|
charged := sync.OnceValue(func() []int {
|
||||||
|
costs := make([]int, len(batches))
|
||||||
|
|
||||||
|
for i, batch := range batches {
|
||||||
|
for _, r := range batch {
|
||||||
|
costs[i] += logfield.EncodedBytes(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return costs
|
||||||
|
})
|
||||||
|
|
||||||
for name, newHandler := range newHandlers() {
|
for name, newHandler := range newHandlers() {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// 'a' is a printable ASCII rune, charged exactly one
|
if first, count := firstUndercharged(newHandler, below.String()); count > 0 {
|
||||||
// byte, so it is the zero point the other runes are
|
t.Errorf(
|
||||||
// measured against.
|
"%d code points below U+1000 cost more than "+
|
||||||
base := renderedWidth(
|
"EncodedBytes charges, the first U+%04X",
|
||||||
newHandler, strings.Repeat("a", sampleRunes),
|
count, first,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for _, r := range chargeTestRunes() {
|
emitted := make([]int, len(batches))
|
||||||
got := renderedWidth(
|
for i, batch := range batches {
|
||||||
newHandler,
|
emitted[i] = emittedBytes(newHandler, batch)
|
||||||
strings.Repeat(string(r), sampleRunes),
|
}
|
||||||
|
|
||||||
|
for i, cost := range charged() {
|
||||||
|
if emitted[i] <= cost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lo := rune(0x1000 + i*batchRunes)
|
||||||
|
first, count := firstUndercharged(
|
||||||
|
newHandler, batches[i],
|
||||||
)
|
)
|
||||||
charged := sampleRunes *
|
t.Errorf(
|
||||||
(logfield.EncodedBytes(r) - 1)
|
"U+%04X to U+%04X emit %d bytes but are "+
|
||||||
|
"charged %d; %d of them cost more than "+
|
||||||
require.LessOrEqual(
|
"EncodedBytes charges, the first U+%04X",
|
||||||
t, got-base, charged+quotingSlack,
|
lo, lo+batchRunes-1, emitted[i], cost,
|
||||||
"U+%04X costs more on the line than "+
|
count, first,
|
||||||
"EncodedBytes charges for it",
|
|
||||||
r,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user