tools: Add src/cts/expectations

Implement an expectation parser, data structures and writer.

Bug: dawn:1342
Change-Id: I53587a9b55346ccf1543e15c9cec5ff68c6849ad
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/87641
Reviewed-by: Dan Sinclair <dsinclair@chromium.org>
Kokoro: Kokoro <noreply+kokoro@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
diff --git a/tools/src/cts/expectations/expectations.go b/tools/src/cts/expectations/expectations.go
new file mode 100644
index 0000000..ae6034c
--- /dev/null
+++ b/tools/src/cts/expectations/expectations.go
@@ -0,0 +1,230 @@
+// Copyright 2022 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package expectations provides types and helpers for parsing, updating and
+// writing WebGPU expectations files.
+//
+// See <dawn>/webgpu-cts/expectations.txt for more information.
+package expectations
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/cts/result"
+)
+
+// Content holds the full content of an expectations file.
+type Content struct {
+	Chunks []Chunk
+	Tags   Tags
+}
+
+// Chunk is an optional comment followed by a run of expectations.
+// A chunk ends at the first blank line, or at the transition from an
+// expectation to a line-comment.
+type Chunk struct {
+	Comments     []string      // Line comments at the top of the chunk
+	Expectations []Expectation // Expectations for the chunk
+}
+
+// Tags holds the tag information parsed in the comments between the
+// 'BEGIN TAG HEADER' and 'END TAG HEADER' markers.
+// Tags are grouped in tag-sets.
+type Tags struct {
+	// Map of tag-set name to tags
+	Sets []TagSet
+	// Map of tag name to tag-set and priority
+	ByName map[string]TagSetAndPriority
+}
+
+// TagSet is a named collection of tags, parsed from the 'TAG HEADER'
+type TagSet struct {
+	Name string      // Name of the tag-set
+	Tags result.Tags // Tags belonging to the tag-set
+}
+
+// TagSetAndPriority is used by the Tags.ByName map to identify which tag-set
+// a tag belongs to.
+type TagSetAndPriority struct {
+	// The tag-set that the tag belongs to.
+	Set string
+	// The declared order of tag in the set.
+	// An expectation may only list a single tag from any set. This priority
+	// is used to decide which tag(s) should be dropped when multiple tags are
+	// found in the same set.
+	Priority int
+}
+
+// Expectation holds a single expectation line
+type Expectation struct {
+	Line    int         // The 1-based line number of the expectation
+	Bug     string      // The associated bug URL for this expectation
+	Tags    result.Tags // Tags used to filter the expectation
+	Query   string      // The CTS query
+	Status  []string    // The expected result status
+	Comment string      // Optional comment at end of line
+}
+
+// Load loads the expectation file at 'path', returning a Content.
+func Load(path string) (Content, error) {
+	content, err := ioutil.ReadFile(path)
+	if err != nil {
+		return Content{}, err
+	}
+	ex, err := Parse(string(content))
+	if err != nil {
+		return Content{}, err
+	}
+	return ex, nil
+}
+
+// Save saves the Content file to 'path'.
+func (c Content) Save(path string) error {
+	f, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	return c.Write(f)
+}
+
+// Clone makes a deep-copy of the Content.
+func (c Content) Clone() Content {
+	chunks := make([]Chunk, len(c.Chunks))
+	for i, c := range c.Chunks {
+		chunks[i] = c.Clone()
+	}
+	return Content{chunks, c.Tags.Clone()}
+}
+
+// Empty returns true if the Content has no chunks.
+func (c Content) Empty() bool {
+	return len(c.Chunks) == 0
+}
+
+// EndsInBlankLine returns true if the Content ends with a blank line
+func (c Content) EndsInBlankLine() bool {
+	return !c.Empty() && c.Chunks[len(c.Chunks)-1].IsBlankLine()
+}
+
+// MaybeAddBlankLine appends a new blank line to the content, if the content
+// does not already end in a blank line.
+func (c *Content) MaybeAddBlankLine() {
+	if !c.Empty() && !c.EndsInBlankLine() {
+		c.Chunks = append(c.Chunks, Chunk{})
+	}
+}
+
+// Write writes the Content, in textual form, to the writer w.
+func (c Content) Write(w io.Writer) error {
+	for _, chunk := range c.Chunks {
+		if len(chunk.Comments) == 0 && len(chunk.Expectations) == 0 {
+			if _, err := fmt.Fprintln(w); err != nil {
+				return err
+			}
+			continue
+		}
+		for _, comment := range chunk.Comments {
+			if _, err := fmt.Fprintln(w, comment); err != nil {
+				return err
+			}
+		}
+		for _, expectation := range chunk.Expectations {
+			parts := []string{}
+			if expectation.Bug != "" {
+				parts = append(parts, expectation.Bug)
+			}
+			if len(expectation.Tags) > 0 {
+				parts = append(parts, fmt.Sprintf("[ %v ]", strings.Join(expectation.Tags.List(), " ")))
+			}
+			parts = append(parts, expectation.Query)
+			parts = append(parts, fmt.Sprintf("[ %v ]", strings.Join(expectation.Status, " ")))
+			if expectation.Comment != "" {
+				parts = append(parts, expectation.Comment)
+			}
+			if _, err := fmt.Fprintln(w, strings.Join(parts, " ")); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// String returns the Content as a string.
+func (c Content) String() string {
+	sb := strings.Builder{}
+	c.Write(&sb)
+	return sb.String()
+}
+
+// IsCommentOnly returns true if the Chunk contains comments and no expectations.
+func (c Chunk) IsCommentOnly() bool {
+	return len(c.Comments) > 0 && len(c.Expectations) == 0
+}
+
+// IsBlankLine returns true if the Chunk has no comments or expectations.
+func (c Chunk) IsBlankLine() bool {
+	return len(c.Comments) == 0 && len(c.Expectations) == 0
+}
+
+// Clone returns a deep-copy of the Chunk
+func (c Chunk) Clone() Chunk {
+	comments := make([]string, len(c.Comments))
+	for i, c := range c.Comments {
+		comments[i] = c
+	}
+	expectations := make([]Expectation, len(c.Expectations))
+	for i, e := range c.Expectations {
+		expectations[i] = e.Clone()
+	}
+	return Chunk{comments, expectations}
+}
+
+// Clone returns a deep-copy of the Tags
+func (t Tags) Clone() Tags {
+	out := Tags{}
+	if t.ByName != nil {
+		out.ByName = make(map[string]TagSetAndPriority, len(t.ByName))
+		for n, t := range t.ByName {
+			out.ByName[n] = t
+		}
+	}
+	if t.Sets != nil {
+		out.Sets = make([]TagSet, len(t.Sets))
+		copy(out.Sets, t.Sets)
+	}
+	return out
+}
+
+// Clone makes a deep-copy of the Expectation.
+func (e Expectation) Clone() Expectation {
+	out := Expectation{
+		Line:    e.Line,
+		Bug:     e.Bug,
+		Query:   e.Query,
+		Comment: e.Comment,
+	}
+	if e.Tags != nil {
+		out.Tags = e.Tags.Clone()
+	}
+	if e.Status != nil {
+		out.Status = append([]string{}, e.Status...)
+	}
+	return out
+}
diff --git a/tools/src/cts/expectations/parse.go b/tools/src/cts/expectations/parse.go
new file mode 100644
index 0000000..7331f03
--- /dev/null
+++ b/tools/src/cts/expectations/parse.go
@@ -0,0 +1,312 @@
+// Copyright 2022 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package expectations
+
+import (
+	"fmt"
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/cts/result"
+)
+
+const (
+	tagHeaderStart = `BEGIN TAG HEADER`
+	tagHeaderEnd   = `END TAG HEADER`
+)
+
+// SyntaxError is the error type returned by Parse() when a syntax error is
+// encountered.
+type SyntaxError struct {
+	Line    int // 1-based
+	Column  int // 1-based
+	Message string
+}
+
+// Error implements the 'error' interface.
+func (e SyntaxError) Error() string {
+	return fmt.Sprintf("%v:%v: %v", e.Line, e.Column, e.Message)
+}
+
+// Parse parses an expectations file, returning the Content
+func Parse(body string) (Content, error) {
+	// LineType is an enumerator classifying the 'type' of the line.
+	type LineType int
+	const (
+		comment     LineType = iota // The line starts with the '#'
+		expectation                 // The line declares an expectation
+		blank                       // The line is blank
+	)
+
+	// classifyLine returns the LineType for the given line
+	classifyLine := func(line string) LineType {
+		line = strings.TrimSpace(line)
+		switch {
+		case line == "":
+			return blank
+		case strings.HasPrefix(line, "#"):
+			return comment
+		default:
+			return expectation
+		}
+	}
+
+	content := Content{} // The output content
+
+	var pending Chunk // The current Chunk being parsed
+
+	// flush completes the current chunk, appending it to 'content'
+	flush := func() {
+		parseTags(&content.Tags, pending.Comments)
+		content.Chunks = append(content.Chunks, pending)
+		pending = Chunk{}
+	}
+
+	lastLineType := blank                         // The type of the last parsed line
+	for i, l := range strings.Split(body, "\n") { // For each line...
+		lineIdx := i + 1 // line index
+		lineType := classifyLine(l)
+
+		// Compare the new line type to the last.
+		// Flush the pending chunk if needed.
+		if i > 0 {
+			switch {
+			case
+				lastLineType == blank && lineType != blank,             // blank -> !blank
+				lastLineType != blank && lineType == blank,             // !blank -> blank
+				lastLineType == expectation && lineType != expectation: // expectation -> comment
+				flush()
+			}
+		}
+
+		lastLineType = lineType
+
+		// Handle blank lines and comments.
+		switch lineType {
+		case blank:
+			continue
+		case comment:
+			pending.Comments = append(pending.Comments, l)
+			continue
+		}
+
+		// Below this point, we're dealing with an expectation
+
+		// Split the line by whitespace to form a list of tokens
+		type Token struct {
+			str        string
+			start, end int // line offsets (0-based)
+		}
+		tokens := []Token{}
+		if len(l) > 0 { // Parse the tokens
+			inToken, s := false, 0
+			for i, c := range l {
+				if c == ' ' {
+					if inToken {
+						tokens = append(tokens, Token{l[s:i], s, i})
+						inToken = false
+					}
+				} else if !inToken {
+					s = i
+					inToken = true
+				}
+			}
+			if inToken {
+				tokens = append(tokens, Token{l[s:], s, len(l)})
+			}
+		}
+
+		// syntaxErr is a helper for returning a SyntaxError with the current
+		// line and column index.
+		syntaxErr := func(at Token, msg string) error {
+			column := at.start + 1
+			if column == 1 {
+				column = len(l) + 1
+			}
+			return SyntaxError{lineIdx, column, msg}
+		}
+
+		// peek returns the next token without consuming it.
+		// If there are no more tokens then an empty Token is returned.
+		peek := func() Token {
+			if len(tokens) > 0 {
+				return tokens[0]
+			}
+			return Token{}
+		}
+
+		// next returns the next token, consuming it and incrementing the
+		// column index.
+		// If there are no more tokens then an empty Token is returned.
+		next := func() Token {
+			if len(tokens) > 0 {
+				tok := tokens[0]
+				tokens = tokens[1:]
+				return tok
+			}
+			return Token{}
+		}
+
+		match := func(str string) bool {
+			if peek().str != str {
+				return false
+			}
+			next()
+			return true
+		}
+
+		// tags parses a [ tag ] block.
+		tags := func(use string) (result.Tags, error) {
+			if !match("[") {
+				return result.Tags{}, nil
+			}
+			out := result.NewTags()
+			for {
+				t := next()
+				switch t.str {
+				case "]":
+					return out, nil
+				case "":
+					return result.Tags{}, syntaxErr(t, "expected ']' for "+use)
+				default:
+					out.Add(t.str)
+				}
+			}
+		}
+
+		// Parse the optional bug
+		var bug string
+		if strings.HasPrefix(peek().str, "crbug.com") {
+			bug = next().str
+		}
+
+		// Parse the optional test tags
+		testTags, err := tags("tags")
+		if err != nil {
+			return Content{}, err
+		}
+
+		// Parse the query
+		if t := peek(); t.str == "" || t.str[0] == '#' || t.str[0] == '[' {
+			return Content{}, syntaxErr(t, "expected test query")
+		}
+		query := next().str
+
+		// Parse the expected status
+		if t := peek(); !strings.HasPrefix(t.str, "[") {
+			return Content{}, syntaxErr(t, "expected status")
+		}
+		status, err := tags("status")
+		if err != nil {
+			return Content{}, err
+		}
+
+		// Parse any optional trailing comment
+		comment := ""
+		if t := peek(); strings.HasPrefix(t.str, "#") {
+			comment = l[t.start:]
+		}
+
+		// Append the expectation to the list.
+		pending.Expectations = append(pending.Expectations, Expectation{
+			Line:    lineIdx,
+			Bug:     bug,
+			Tags:    testTags,
+			Query:   query,
+			Status:  status.List(),
+			Comment: comment,
+		})
+	}
+
+	if lastLineType != blank {
+		flush()
+	}
+
+	return content, nil
+}
+
+// parseTags parses the tag information found between tagHeaderStart and
+// tagHeaderEnd comments.
+func parseTags(tags *Tags, lines []string) {
+	// Flags for whether we're currently parsing a TAG HEADER and whether we're
+	// also within a tag-set.
+	inTagsHeader, inTagSet := false, false
+	tagSet := TagSet{} // The currently parsed tag-set
+	for _, line := range lines {
+		line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
+		if strings.Contains(line, tagHeaderStart) {
+			if tags.ByName == nil {
+				*tags = Tags{
+					ByName: map[string]TagSetAndPriority{},
+					Sets:   []TagSet{},
+				}
+			}
+			inTagsHeader = true
+			continue
+		}
+		if strings.Contains(line, tagHeaderEnd) {
+			return // Reached the end of the TAG HEADER
+		}
+		if !inTagsHeader {
+			continue // Still looking for a tagHeaderStart
+		}
+
+		// Below this point, we're in a TAG HEADER.
+		tokens := removeEmpty(strings.Split(line, " "))
+		for len(tokens) > 0 {
+			if inTagSet {
+				// Parsing tags in a tag-set (between the '[' and ']')
+				if tokens[0] == "]" {
+					// End of the tag-set.
+					tags.Sets = append(tags.Sets, tagSet)
+					inTagSet = false
+					break
+				} else {
+					// Still inside the tag-set. Consume the tag.
+					tag := tokens[0]
+					tags.ByName[tag] = TagSetAndPriority{
+						Set:      tagSet.Name,
+						Priority: len(tagSet.Tags),
+					}
+					tagSet.Tags.Add(tag)
+				}
+				tokens = tokens[1:]
+			} else {
+				// Outside of tag-set. Scan for 'tags: ['
+				if len(tokens) > 2 && tokens[0] == "tags:" && tokens[1] == "[" {
+					inTagSet = true
+					tagSet.Tags = result.NewTags()
+					tokens = tokens[2:] // Skip 'tags:' and '['
+				} else {
+					// Tag set names are on their own line.
+					// Remember the content of the line, in case the next line
+					// starts a tag-set.
+					tagSet.Name = strings.Join(tokens, " ")
+					break
+				}
+			}
+		}
+	}
+}
+
+// removeEmpty returns the list of strings with all empty strings removed.
+func removeEmpty(in []string) []string {
+	out := make([]string, 0, len(in))
+	for _, s := range in {
+		if s != "" {
+			out = append(out, s)
+		}
+	}
+	return out
+}
diff --git a/tools/src/cts/expectations/parse_test.go b/tools/src/cts/expectations/parse_test.go
new file mode 100644
index 0000000..af233e1
--- /dev/null
+++ b/tools/src/cts/expectations/parse_test.go
@@ -0,0 +1,472 @@
+// Copyright 2022 The Dawn Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package expectations_test
+
+import (
+	"testing"
+
+	"dawn.googlesource.com/dawn/tools/src/cts/expectations"
+	"dawn.googlesource.com/dawn/tools/src/cts/result"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestParse(t *testing.T) {
+	type Test struct {
+		name      string
+		in        string
+		expect    expectations.Content
+		expectErr string
+	}
+	for _, test := range []Test{
+		{
+			name:   "empty",
+			in:     ``,
+			expect: expectations.Content{},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "single line comment",
+			in:   `# a comment`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{Comments: []string{`# a comment`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "single line comment, followed by newline",
+			in: `# a comment
+`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{Comments: []string{`# a comment`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "newline, followed by single line comment",
+			in: `
+# a comment`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{},
+					{Comments: []string{`# a comment`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "comments separated by single newline",
+			in: `# comment 1
+# comment 2`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Comments: []string{
+							`# comment 1`,
+							`# comment 2`,
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "comments separated by two newlines",
+			in: `# comment 1
+
+# comment 2`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{Comments: []string{`# comment 1`}},
+					{},
+					{Comments: []string{`# comment 2`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "comments separated by multiple newlines",
+			in: `# comment 1
+
+
+
+# comment 2`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{Comments: []string{`# comment 1`}},
+					{},
+					{Comments: []string{`# comment 2`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, single result",
+			in:   `abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Tags:   result.NewTags(),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with comment",
+			in:   `abc,def [ FAIL ] # this is a comment`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:    1,
+								Tags:    result.NewTags(),
+								Query:   "abc,def",
+								Status:  []string{"FAIL"},
+								Comment: "# this is a comment",
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, multiple results",
+			in:   `abc,def [ FAIL SLOW ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Tags:   result.NewTags(),
+								Query:  "abc,def",
+								Status: []string{"FAIL", "SLOW"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with single tag",
+			in:   `[ Win ] abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Tags:   result.NewTags("Win"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with multiple tags",
+			in:   `[ Win Mac ] abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Tags:   result.NewTags("Win", "Mac"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with bug",
+			in:   `crbug.com/123 abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags(),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with bug and tag",
+			in:   `crbug.com/123 [ Win ] abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   1,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags("Win"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with comment",
+			in: `# a comment
+crbug.com/123 [ Win ] abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Comments: []string{`# a comment`},
+						Expectations: []expectations.Expectation{
+							{
+								Line:   2,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags("Win"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "expectation, with multiple comments",
+			in: `# comment 1
+# comment 2
+crbug.com/123 [ Win ] abc,def [ FAIL ]`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Comments: []string{`# comment 1`, `# comment 2`},
+						Expectations: []expectations.Expectation{
+							{
+								Line:   3,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags("Win"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "comment, test, newline, comment",
+			in: `# comment 1
+crbug.com/123 abc_def [ Skip ]
+
+### comment 2`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{
+						Comments: []string{`# comment 1`},
+						Expectations: []expectations.Expectation{
+							{
+								Line:   2,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags(),
+								Query:  "abc_def",
+								Status: []string{"Skip"},
+							},
+						},
+					},
+					{},
+					{Comments: []string{`### comment 2`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "complex",
+			in: `# comment 1
+
+# comment 2
+# comment 3
+
+crbug.com/123 [ Win ] abc,def [ FAIL ]
+
+# comment 4
+# comment 5
+crbug.com/456 [ Mac ] ghi_jkl [ PASS ]
+# comment 6
+
+# comment 7
+`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{Comments: []string{`# comment 1`}},
+					{},
+					{Comments: []string{`# comment 2`, `# comment 3`}},
+					{},
+					{
+						Expectations: []expectations.Expectation{
+							{
+								Line:   6,
+								Bug:    "crbug.com/123",
+								Tags:   result.NewTags("Win"),
+								Query:  "abc,def",
+								Status: []string{"FAIL"},
+							},
+						},
+					},
+					{},
+					{
+						Comments: []string{`# comment 4`, `# comment 5`},
+						Expectations: []expectations.Expectation{
+							{
+								Line:   10,
+								Bug:    "crbug.com/456",
+								Tags:   result.NewTags("Mac"),
+								Query:  "ghi_jkl",
+								Status: []string{"PASS"},
+							},
+						},
+					},
+					{Comments: []string{`# comment 6`}},
+					{},
+					{Comments: []string{`# comment 7`}},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name: "tag header",
+			in: `
+# BEGIN TAG HEADER (autogenerated, see validate_tag_consistency.py)
+# Devices
+# tags: [ duck-fish-5 duck-fish-5x duck-horse-2 duck-horse-4
+#             duck-horse-6 duck-shield-duck-tv
+#         mouse-snake-frog mouse-snake-ant mouse-snake
+#         fly-snake-bat fly-snake-worm fly-snake-snail-rabbit ]
+# Platform
+# tags: [ hamster
+#         lion ]
+# Driver
+# tags: [ goat.1 ]
+# END TAG HEADER
+`,
+			expect: expectations.Content{
+				Chunks: []expectations.Chunk{
+					{},
+					{Comments: []string{
+						`# BEGIN TAG HEADER (autogenerated, see validate_tag_consistency.py)`,
+						`# Devices`,
+						`# tags: [ duck-fish-5 duck-fish-5x duck-horse-2 duck-horse-4`,
+						`#             duck-horse-6 duck-shield-duck-tv`,
+						`#         mouse-snake-frog mouse-snake-ant mouse-snake`,
+						`#         fly-snake-bat fly-snake-worm fly-snake-snail-rabbit ]`,
+						`# Platform`,
+						`# tags: [ hamster`,
+						`#         lion ]`,
+						`# Driver`,
+						`# tags: [ goat.1 ]`,
+						`# END TAG HEADER`,
+					}},
+				},
+				Tags: expectations.Tags{
+					ByName: map[string]expectations.TagSetAndPriority{
+						"duck-fish-5":            {Set: "Devices", Priority: 0},
+						"duck-fish-5x":           {Set: "Devices", Priority: 1},
+						"duck-horse-2":           {Set: "Devices", Priority: 2},
+						"duck-horse-4":           {Set: "Devices", Priority: 3},
+						"duck-horse-6":           {Set: "Devices", Priority: 4},
+						"duck-shield-duck-tv":    {Set: "Devices", Priority: 5},
+						"mouse-snake-frog":       {Set: "Devices", Priority: 6},
+						"mouse-snake-ant":        {Set: "Devices", Priority: 7},
+						"mouse-snake":            {Set: "Devices", Priority: 8},
+						"fly-snake-bat":          {Set: "Devices", Priority: 9},
+						"fly-snake-worm":         {Set: "Devices", Priority: 10},
+						"fly-snake-snail-rabbit": {Set: "Devices", Priority: 11},
+						"hamster":                {Set: "Platform", Priority: 0},
+						"lion":                   {Set: "Platform", Priority: 1},
+						"goat.1":                 {Set: "Driver", Priority: 0},
+					},
+					Sets: []expectations.TagSet{
+						{
+							Name: "Devices",
+							Tags: result.NewTags(
+								"duck-fish-5", "duck-fish-5x", "duck-horse-2",
+								"duck-horse-4", "duck-horse-6", "duck-shield-duck-tv",
+								"mouse-snake-frog", "mouse-snake-ant", "mouse-snake",
+								"fly-snake-bat", "fly-snake-worm", "fly-snake-snail-rabbit",
+							),
+						}, {
+							Name: "Platform",
+							Tags: result.NewTags("hamster", "lion"),
+						}, {
+							Name: "Driver",
+							Tags: result.NewTags("goat.1"),
+						},
+					},
+				},
+			},
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name:      "err missing tag ']'",
+			in:        `[`,
+			expectErr: "1:2: expected ']' for tags",
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name:      "err missing test query",
+			in:        `[ a ]`,
+			expectErr: "1:6: expected test query",
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name:      "err missing status EOL",
+			in:        `[ a ] b`,
+			expectErr: "1:8: expected status",
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name:      "err missing status comment",
+			in:        `[ a ] b # c`,
+			expectErr: "1:9: expected status",
+		}, /////////////////////////////////////////////////////////////////////
+		{
+			name:      "err missing status ']'",
+			in:        `[ a ] b [ c`,
+			expectErr: "1:12: expected ']' for status",
+		},
+	} {
+
+		got, err := expectations.Parse(test.in)
+		errMsg := ""
+		if err != nil {
+			errMsg = err.Error()
+		}
+		if diff := cmp.Diff(errMsg, test.expectErr); diff != "" {
+			t.Errorf("'%v': Parse() error %v", test.name, diff)
+			continue
+		}
+		if diff := cmp.Diff(got, test.expect); diff != "" {
+			t.Errorf("'%v': Parse() was not as expected:\n%v", test.name, diff)
+		}
+	}
+}