557 lines
17 KiB
Go
557 lines
17 KiB
Go
package fa
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// rewritingRT routes every request to a fixed test server while preserving
|
|
// the original path and query. Lets us exercise the SDK end-to-end against
|
|
// canned responses without monkey-patching the urls package.
|
|
type rewritingRT struct {
|
|
target *url.URL
|
|
base http.RoundTripper
|
|
}
|
|
|
|
func (r *rewritingRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req2 := req.Clone(req.Context())
|
|
req2.URL = &url.URL{
|
|
Scheme: r.target.Scheme,
|
|
Host: r.target.Host,
|
|
Path: req.URL.Path,
|
|
RawQuery: req.URL.RawQuery,
|
|
}
|
|
req2.Host = r.target.Host
|
|
return r.base.RoundTrip(req2)
|
|
}
|
|
|
|
func newE2EClient(t *testing.T, srv *httptest.Server) *Client {
|
|
t.Helper()
|
|
target, err := url.Parse(srv.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse server url: %v", err)
|
|
}
|
|
hc := &http.Client{
|
|
Transport: &rewritingRT{target: target, base: http.DefaultTransport},
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
// Tight rate limit so tests don't sleep for seconds between pages.
|
|
return New(
|
|
WithHTTPClient(hc),
|
|
WithRateLimit(time.Microsecond, 16),
|
|
WithMaxRetries(0),
|
|
)
|
|
}
|
|
|
|
func TestE2E_GetSubmission(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/view/1234/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte(syntheticSubmissionHTML))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
sub, err := client.GetSubmission(context.Background(), 1234)
|
|
if err != nil {
|
|
t.Fatalf("GetSubmission: %v", err)
|
|
}
|
|
if sub.Title != "My Test Submission" {
|
|
t.Errorf("Title = %q", sub.Title)
|
|
}
|
|
if sub.Author.Name != "somefurry" {
|
|
t.Errorf("Author.Name = %q", sub.Author.Name)
|
|
}
|
|
if sub.Rating != RatingGeneral {
|
|
t.Errorf("Rating = %q", sub.Rating)
|
|
}
|
|
}
|
|
|
|
func TestE2E_GalleryIterator_WalksPages(t *testing.T) {
|
|
page1 := `<html><body>
|
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
|
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
|
</body></html>`
|
|
page2 := `<html><body>
|
|
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a></figure>
|
|
</body></html>`
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte(page1))
|
|
})
|
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte(page2))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
var ids []SubmissionID
|
|
for sub, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
ids = append(ids, sub.ID)
|
|
}
|
|
wantIDs := []SubmissionID{1, 2, 3}
|
|
if len(ids) != len(wantIDs) {
|
|
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
|
|
}
|
|
for i, id := range wantIDs {
|
|
if ids[i] != id {
|
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
|
}
|
|
}
|
|
if hits.Load() != 2 {
|
|
t.Errorf("page fetches = %d; want 2", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_GalleryIterator_EarlyBreakStopsFetching(t *testing.T) {
|
|
page1 := `<html><body>
|
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a></figure>
|
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
|
</body></html>`
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(page1))
|
|
})
|
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
t.Error("page 2 should not have been fetched after early break")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
for _, err := range client.Gallery(context.Background(), "me", ListOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
break // stop after first
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("hits = %d; want 1", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_SystemMessage_NotFound(t *testing.T) {
|
|
notFoundHTML := `<html>
|
|
<head><title>System Error</title></head>
|
|
<body>
|
|
<section>
|
|
<div class="section-header"><h2>System Error</h2></div>
|
|
<div class="section-body">The submission you are trying to find is not in our database.</div>
|
|
</section>
|
|
</body></html>`
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/view/999/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(notFoundHTML))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
_, err := client.GetSubmission(context.Background(), 999)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("got %v; want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestE2E_BrowseIterator_WalksPagesByFullness(t *testing.T) {
|
|
// Build a full page (browsePerPage items) so the iterator keeps going,
|
|
// then a half page (< browsePerPage) so it stops naturally.
|
|
fullPage := buildFigurePage(browsePerPage, 1)
|
|
tailPage := buildFigurePage(browsePerPage/2, 1+browsePerPage)
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
page := r.URL.Query().Get("page")
|
|
w.Header().Set("Content-Type", "text/html")
|
|
switch page {
|
|
case "", "1":
|
|
_, _ = w.Write([]byte(fullPage))
|
|
case "2":
|
|
_, _ = w.Write([]byte(tailPage))
|
|
default:
|
|
t.Errorf("unexpected page=%q requested", page)
|
|
}
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
|
|
count := 0
|
|
for sub, err := range client.Browse(context.Background(), BrowseOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
if sub.ID == 0 {
|
|
t.Errorf("yielded submission with ID 0")
|
|
}
|
|
count++
|
|
}
|
|
wantCount := browsePerPage + browsePerPage/2
|
|
if count != wantCount {
|
|
t.Errorf("yielded %d items; want %d", count, wantCount)
|
|
}
|
|
if hits.Load() != 2 {
|
|
t.Errorf("hit count = %d; want 2 (page=1 + page=2)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_BrowseIterator_EarlyBreakStopsFetching(t *testing.T) {
|
|
fullPage := buildFigurePage(browsePerPage, 1)
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(fullPage))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
for _, err := range client.Browse(context.Background(), BrowseOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
break // stop after the very first item
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("hit count = %d; want 1 (early break before page 2)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_BrowseIterator_MaxPagesCap(t *testing.T) {
|
|
full := buildFigurePage(browsePerPage, 1)
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/browse/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(full))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
count := 0
|
|
for _, err := range client.Browse(context.Background(), BrowseOptions{MaxPages: 2}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
count++
|
|
}
|
|
if hits.Load() != 2 {
|
|
t.Errorf("hits = %d; want 2 (BrowseOptions.MaxPages=2 should cap fetches)", hits.Load())
|
|
}
|
|
if count != 2*browsePerPage {
|
|
t.Errorf("count = %d; want %d", count, 2*browsePerPage)
|
|
}
|
|
}
|
|
|
|
// buildFigurePage constructs a synthetic browse-style HTML page with n
|
|
// figure[id^=sid-] items, starting from idStart. Each figure mirrors the
|
|
// real FA structure closely enough to satisfy parseGalleryPage.
|
|
func buildFigurePage(n int, idStart int) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<html><body>`)
|
|
for i := 0; i < n; i++ {
|
|
id := idStart + i
|
|
fmt.Fprintf(&b, `<figure id="sid-%d" class="r-general t-image u-artist%d">
|
|
<a href="/view/%d/" title="Submission %d"><img src="/t%d.png"/></a>
|
|
<figcaption>
|
|
<p><a href="/view/%d/" title="Submission %d">Submission %d</a></p>
|
|
<p><i>by</i> <a href="/user/artist%d/" title="Artist%d">Artist%d</a></p>
|
|
</figcaption>
|
|
</figure>
|
|
`, id, i, id, id, id, id, id, id, i, i, i)
|
|
}
|
|
b.WriteString(`</body></html>`)
|
|
return b.String()
|
|
}
|
|
|
|
func TestE2E_Search_FollowsNextPagination(t *testing.T) {
|
|
page1 := `<html><body>
|
|
<section id="gallery-search-results" class="gallery">
|
|
<figure id="sid-10"><a href="/view/10/" title="Ten"><img src="/t10.png"/></a>
|
|
<figcaption><p><a href="/view/10/" title="Ten">Ten</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
|
|
</figure>
|
|
<figure id="sid-11"><a href="/view/11/" title="Eleven"><img src="/t11.png"/></a>
|
|
<figcaption><p><a href="/view/11/" title="Eleven">Eleven</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
|
|
</figure>
|
|
</section>
|
|
<div class="pagination">
|
|
<button class="button" disabled>Back</button>
|
|
<a class="button" href="/search/?q=x&page=2">Next</a>
|
|
</div>
|
|
</body></html>`
|
|
page2 := `<html><body>
|
|
<section id="gallery-search-results" class="gallery">
|
|
<figure id="sid-12"><a href="/view/12/" title="Twelve"><img src="/t12.png"/></a>
|
|
<figcaption><p><a href="/view/12/" title="Twelve">Twelve</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
|
|
</figure>
|
|
</section>
|
|
<div class="pagination">
|
|
<a class="button" href="/search/?q=x&page=1">Back</a>
|
|
</div>
|
|
</body></html>`
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
page := r.URL.Query().Get("page")
|
|
if page == "2" {
|
|
_, _ = w.Write([]byte(page2))
|
|
} else {
|
|
_, _ = w.Write([]byte(page1))
|
|
}
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
var ids []SubmissionID
|
|
for sub, err := range client.Search(context.Background(), "x", SearchOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
ids = append(ids, sub.ID)
|
|
}
|
|
wantIDs := []SubmissionID{10, 11, 12}
|
|
if len(ids) != len(wantIDs) {
|
|
t.Fatalf("got %d ids; want %d (%v)", len(ids), len(wantIDs), ids)
|
|
}
|
|
for i, id := range wantIDs {
|
|
if ids[i] != id {
|
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
|
}
|
|
}
|
|
if hits.Load() != 2 {
|
|
t.Errorf("hits = %d; want 2", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_Search_RespectsMaxPages(t *testing.T) {
|
|
full := `<html><body>
|
|
<section id="gallery-search-results">
|
|
<figure id="sid-1"><a href="/view/1/" title="A"><img src="/t.png"/></a>
|
|
<figcaption><p><a href="/view/1/" title="A">A</a></p><p>by <a href="/user/x/">X</a></p></figcaption>
|
|
</figure>
|
|
</section>
|
|
<div class="pagination"><a class="button" href="/search/?page=99">Next</a></div>
|
|
</body></html>`
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/search/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(full))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
for _, err := range client.Search(context.Background(), "x", SearchOptions{MaxPages: 3}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
}
|
|
if hits.Load() != 3 {
|
|
t.Errorf("hits = %d; want 3 (MaxPages cap)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestE2E_SubmissionInbox_FollowsCursorPagination(t *testing.T) {
|
|
// Build a fake inbox page with the date-divider wrapper and a cursor
|
|
// link that points at a second page; the second page omits the cursor
|
|
// so the iterator stops naturally.
|
|
page1 := `<html><body>
|
|
<div id="messagecenter-submissions">
|
|
<div class="notifications-by-date" data-date="1779177165">
|
|
<h4>Today</h4>
|
|
<section class="gallery">
|
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a>
|
|
<figcaption><p><a href="/view/1/" title="One">One</a></p><p>by <a href="/user/alice/">Alice</a></p></figcaption>
|
|
</figure>
|
|
<figure id="sid-2"><a href="/view/2/" title="Two"><img src="/t2.png"/></a>
|
|
<figcaption><p><a href="/view/2/" title="Two">Two</a></p><p>by <a href="/user/bob/">Bob</a></p></figcaption>
|
|
</figure>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
<div class="messagecenter-navigation">
|
|
<a class="button standard more" href="/msg/submissions/new~2@72/">Next 72</a>
|
|
</div>
|
|
</body></html>`
|
|
page2 := `<html><body>
|
|
<div id="messagecenter-submissions">
|
|
<div class="notifications-by-date" data-date="1779000000">
|
|
<h4>Yesterday</h4>
|
|
<section class="gallery">
|
|
<figure id="sid-3"><a href="/view/3/" title="Three"><img src="/t3.png"/></a>
|
|
<figcaption><p><a href="/view/3/" title="Three">Three</a></p><p>by <a href="/user/carol/">Carol</a></p></figcaption>
|
|
</figure>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</body></html>`
|
|
|
|
var hits atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(page1))
|
|
})
|
|
mux.HandleFunc("/msg/submissions/new~2@72/", func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
_, _ = w.Write([]byte(page2))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
var ids []SubmissionID
|
|
var posted []time.Time
|
|
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
ids = append(ids, sub.ID)
|
|
posted = append(posted, sub.PostedAt)
|
|
}
|
|
wantIDs := []SubmissionID{1, 2, 3}
|
|
if len(ids) != len(wantIDs) {
|
|
t.Fatalf("got %d ids; want %d", len(ids), len(wantIDs))
|
|
}
|
|
for i, id := range wantIDs {
|
|
if ids[i] != id {
|
|
t.Errorf("ids[%d] = %d; want %d", i, ids[i], id)
|
|
}
|
|
}
|
|
if posted[0].IsZero() || posted[2].IsZero() {
|
|
t.Errorf("PostedAt not populated from data-date: %v / %v", posted[0], posted[2])
|
|
}
|
|
if hits.Load() != 2 {
|
|
t.Errorf("hits = %d; want 2", hits.Load())
|
|
}
|
|
}
|
|
|
|
// inboxPageHTML builds a /msg/submissions/ page wrapping figures for the
|
|
// given submission IDs (newest first) in FA's date-divider markup.
|
|
func inboxPageHTML(ids []int) string {
|
|
var figs strings.Builder
|
|
for _, id := range ids {
|
|
fmt.Fprintf(&figs,
|
|
`<figure id="sid-%d"><a href="/view/%d/" title="S%d"><img src="/t.png"/></a></figure>`,
|
|
id, id, id)
|
|
}
|
|
return `<html><body><div id="messagecenter-submissions">` +
|
|
`<div class="notifications-by-date" data-date="1779177165">` +
|
|
`<section class="gallery">` + figs.String() + `</section></div></div></body></html>`
|
|
}
|
|
|
|
// When FA serves a full inbox page (72 items) but omits the "Next 72"
|
|
// cursor link, the iterator must keep crawling by synthesizing the cursor
|
|
// from the oldest submission on the page otherwise a multi-thousand-item
|
|
// inbox is truncated to its first page (SDK issue #23).
|
|
func TestE2E_SubmissionInbox_SynthesizesCursorWhenLinkMissing(t *testing.T) {
|
|
// Page 1: a full page (72 items, ids 200..129) with NO "Next 72" link.
|
|
page1IDs := make([]int, 0, 72)
|
|
for id := 200; id >= 129; id-- {
|
|
page1IDs = append(page1IDs, id)
|
|
}
|
|
// Page 2: the synthesized cursor lands here; a short final page.
|
|
page2IDs := make([]int, 0, 30)
|
|
for id := 128; id >= 99; id-- {
|
|
page2IDs = append(page2IDs, id)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/msg/submissions/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(inboxPageHTML(page1IDs)))
|
|
})
|
|
mux.HandleFunc("/msg/submissions/new~129@72/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(inboxPageHTML(page2IDs)))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
var got []int
|
|
for sub, err := range client.SubmissionInbox(context.Background(), ListOptions{}) {
|
|
if err != nil {
|
|
t.Fatalf("iter: %v", err)
|
|
}
|
|
got = append(got, int(sub.ID))
|
|
}
|
|
if len(got) != 102 {
|
|
t.Fatalf("got %d items; want 102 (72 + 30)", len(got))
|
|
}
|
|
if got[0] != 200 || got[101] != 99 {
|
|
t.Errorf("boundary ids = %d..%d; want 200..99", got[0], got[101])
|
|
}
|
|
}
|
|
|
|
func TestE2E_ContextCancelInterruptsIterator(t *testing.T) {
|
|
page := `<html><body>
|
|
<figure id="sid-1"><a href="/view/1/" title="One"><img src="/t1.png"/></a></figure>
|
|
<a class="button standard" href="/gallery/me/2/">Next</a>
|
|
</body></html>`
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/gallery/me/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(page))
|
|
})
|
|
mux.HandleFunc("/gallery/me/2/", func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := newE2EClient(t, srv)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
gotErr := error(nil)
|
|
count := 0
|
|
go func() {
|
|
time.Sleep(30 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
for _, err := range client.Gallery(ctx, "me", ListOptions{}) {
|
|
if err != nil {
|
|
gotErr = err
|
|
break
|
|
}
|
|
count++
|
|
}
|
|
if gotErr == nil {
|
|
t.Fatal("expected cancellation error; iterator completed normally")
|
|
}
|
|
}
|