fix: keep the Do oceny preview under cabana's partial cap
WR-04: cut each long text of the review context view model to an escaped-byte budget, so a 20000-character answer of characters that html/template expands to 5 bytes can no longer push the partial over 64 KiB. Ordinary answers stay whole; a cut text gets a note pointing to quizzes_review_show.
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
||||
@@ -421,6 +422,61 @@ type reviewContextView struct {
|
||||
ReviewComment string
|
||||
Reviewed bool
|
||||
ReviewedAt string
|
||||
// TextCut and Cut tell that the student's text, or another long text,
|
||||
// was shortened to fit cabana's partial size cap (see fitEscaped).
|
||||
TextCut bool
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// Escaped-byte budgets of the partial's long texts. cabana refuses a
|
||||
// partial that renders to more than 64 KiB, and html/template turns one
|
||||
// character into up to 5 bytes, so each text is cut to its budget measured
|
||||
// as escaped (fitEscaped). Their sum leaves room for the markup and the
|
||||
// translated labels; a long but ordinary answer (max_length up to 20000)
|
||||
// fits whole, only pathological texts are cut.
|
||||
const (
|
||||
budgetText = 32 << 10
|
||||
budgetQuestionText = 4 << 10
|
||||
budgetModelAnswer = 6 << 10
|
||||
budgetRubric = 6 << 10
|
||||
budgetFeedback = 4 << 10
|
||||
budgetComment = 4 << 10
|
||||
budgetError = 1 << 10
|
||||
budgetShort = 512
|
||||
)
|
||||
|
||||
// fitEscaped cuts s so that it takes at most budget bytes once escaped by
|
||||
// html/template, ending it with "…" when cut, and reports whether it cut.
|
||||
func fitEscaped(s string, budget int) (string, bool) {
|
||||
total := 0
|
||||
for _, r := range s {
|
||||
total += escapedLen(r)
|
||||
}
|
||||
if total <= budget {
|
||||
return s, false
|
||||
}
|
||||
used := 0
|
||||
for i, r := range s {
|
||||
used += escapedLen(r)
|
||||
if used > budget-len("…") {
|
||||
return s[:i] + "…", true
|
||||
}
|
||||
}
|
||||
return s, false
|
||||
}
|
||||
|
||||
// escapedLen is the most bytes html/template writes for r in text.
|
||||
func escapedLen(r rune) int {
|
||||
switch r {
|
||||
case '"', '&', '\'', '+', '<', '>':
|
||||
return 5
|
||||
case 0:
|
||||
return len("\uFFFD")
|
||||
}
|
||||
if n := utf8.RuneLen(r); n > 0 {
|
||||
return n
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// PartialData builds the review context of an answer from the review
|
||||
@@ -445,7 +501,8 @@ func (reviewsAdmin) PartialData(ctx context.Context, name string, record any) (a
|
||||
}
|
||||
|
||||
// newReviewContextView copies a review detail into the partial's view
|
||||
// model.
|
||||
// model, each long text cut to its escaped-byte budget so the partial
|
||||
// always fits cabana's size cap.
|
||||
func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) reviewContextView {
|
||||
v := reviewContextView{
|
||||
QuizTitle: item.QuizTitle,
|
||||
@@ -473,6 +530,25 @@ func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) rev
|
||||
v.HasGraderPoints, v.GraderPoints = true, *item.GraderPoints
|
||||
}
|
||||
v.GraderLabel = graderLabel(reg, v.Grader)
|
||||
v.Text, v.TextCut = fitEscaped(v.Text, budgetText)
|
||||
for _, f := range []struct {
|
||||
s *string
|
||||
budget int
|
||||
}{
|
||||
{&v.QuizTitle, budgetShort},
|
||||
{&v.StudentEmail, budgetShort},
|
||||
{&v.StudentName, budgetShort},
|
||||
{&v.QuestionText, budgetQuestionText},
|
||||
{&v.ModelAnswer, budgetModelAnswer},
|
||||
{&v.Rubric, budgetRubric},
|
||||
{&v.GraderFeedback, budgetFeedback},
|
||||
{&v.GradingError, budgetError},
|
||||
{&v.ReviewComment, budgetComment},
|
||||
} {
|
||||
var cut bool
|
||||
*f.s, cut = fitEscaped(*f.s, f.budget)
|
||||
v.Cut = v.Cut || cut
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<div class="quizzes-review">
|
||||
{{with .Data}}
|
||||
{{if .Cut}}
|
||||
<p class="help-block">{{trans "golem15.quizzes::lang.reviews.cut"}}</p>
|
||||
{{end}}
|
||||
<dl>
|
||||
<dt>{{trans "golem15.quizzes::lang.reviews.quiz"}}</dt>
|
||||
<dd>{{.QuizTitle}}</dd>
|
||||
@@ -16,7 +19,7 @@
|
||||
<dd><p class="whitespace-pre-wrap">{{.Rubric}}</p></dd>
|
||||
{{end}}
|
||||
<dt>{{trans "golem15.quizzes::lang.reviews.text"}} <span class="help-block">{{trans "golem15.quizzes::lang.reviews.max_length"}}: {{.MaxLength}}</span></dt>
|
||||
<dd><blockquote class="whitespace-pre-wrap">{{.Text}}</blockquote></dd>
|
||||
<dd><blockquote class="whitespace-pre-wrap">{{.Text}}</blockquote>{{if .TextCut}}<p class="help-block">{{trans "golem15.quizzes::lang.reviews.text_cut"}}</p>{{end}}</dd>
|
||||
<dt>{{trans "golem15.quizzes::lang.reviews.status"}}</dt>
|
||||
<dd><strong>{{trans .StatusLabel}}</strong></dd>
|
||||
{{if .Grader}}
|
||||
|
||||
@@ -209,3 +209,58 @@ func TestReviewContextEscapes(t *testing.T) {
|
||||
t.Errorf("student text not escaped: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitEscaped(t *testing.T) {
|
||||
if s, cut := fitEscaped("zażółć", 12); s != "zażółć" || cut {
|
||||
t.Errorf("a text within budget = %q, %v", s, cut)
|
||||
}
|
||||
s, cut := fitEscaped(strings.Repeat(`"`, 10), 23)
|
||||
if !cut || s != `""""`+"…" {
|
||||
t.Errorf("quotes cut = %q, %v; want four quotes and an ellipsis", s, cut)
|
||||
}
|
||||
if s, cut := fitEscaped("abc", 0); !cut || s != "…" {
|
||||
t.Errorf("zero budget = %q, %v", s, cut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewContextFitsPartialCap renders the partial with every text at
|
||||
// its worst case (characters html/template turns into 5 bytes) and checks
|
||||
// the output stays under cabana's 64 KiB partial cap.
|
||||
func TestReviewContextFitsPartialCap(t *testing.T) {
|
||||
src, err := os.ReadFile("reviews/_review_context.htm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A translation longer than any real label.
|
||||
long := strings.Repeat("ą", 100)
|
||||
tpl, err := template.New("review_context").Funcs(template.FuncMap{"trans": func(string) string { return long }}).Parse(string(src))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
worst := func(n int) *string { s := strings.Repeat(`"&'`, n); return &s }
|
||||
grader := "ai"
|
||||
gp := 1.5
|
||||
v := newReviewContextView(&classes.ReviewDetail{
|
||||
ReviewRow: classes.ReviewRow{
|
||||
Status: "reviewed", QuizTitle: *worst(300), Email: *worst(300), Name: worst(300), Surname: worst(300),
|
||||
QuestionText: *worst(10000), Text: *worst(20000), GraderPoints: &gp,
|
||||
},
|
||||
AnswerText: worst(10000), Rubric: worst(10000), Grader: &grader,
|
||||
GraderFeedback: worst(2000), GradingError: worst(500), ReviewComment: worst(2000),
|
||||
}, nil)
|
||||
if !v.TextCut || !v.Cut {
|
||||
t.Fatalf("worst-case texts not cut: TextCut=%v Cut=%v", v.TextCut, v.Cut)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := tpl.Execute(&out, map[string]any{"Data": v}); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if n := out.Len(); n > 64<<10 {
|
||||
t.Errorf("partial renders to %d bytes, over the 64 KiB cap", n)
|
||||
}
|
||||
|
||||
plain := strings.Repeat("Długa, zwykła odpowiedź studenta. ", 580)
|
||||
if v := newReviewContextView(&classes.ReviewDetail{ReviewRow: classes.ReviewRow{Text: plain}}, nil); v.TextCut || v.Text != plain {
|
||||
t.Error("an ordinary answer near 20000 characters was cut")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,5 +129,7 @@ reviews:
|
||||
already_reviewed: This answer is already verified.
|
||||
grader_manual: This quiz is graded manually; nothing can grade the answer again.
|
||||
attempt_open: The test is still in progress; review this answer once it ends.
|
||||
text_cut: The answer is too long to show here in full; the quizzes_review_show tool shows all of it.
|
||||
cut: Some long texts are shortened on this screen; the quizzes_review_show tool shows them in full.
|
||||
not_found: This answer was not found.
|
||||
not_created: Open answers come from students; they cannot be added or deleted here.
|
||||
|
||||
@@ -129,5 +129,7 @@ reviews:
|
||||
already_reviewed: Ta odpowiedź jest już zweryfikowana.
|
||||
grader_manual: Ten quiz jest oceniany ręcznie; nie ma kto ocenić odpowiedzi ponownie.
|
||||
attempt_open: Test wciąż trwa; oceń tę odpowiedź po jego zakończeniu.
|
||||
text_cut: Odpowiedź jest zbyt długa, by pokazać ją tu w całości; całą pokazuje narzędzie quizzes_review_show.
|
||||
cut: Część długich tekstów jest tu skrócona; w całości pokazuje je narzędzie quizzes_review_show.
|
||||
not_found: Nie znaleziono tej odpowiedzi.
|
||||
not_created: Odpowiedzi otwarte pochodzą od studentów; tutaj nie można ich dodawać ani usuwać.
|
||||
|
||||
Reference in New Issue
Block a user