Files
90CNServer/pvf/services/advanced_search_sqlite_test.go
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

709 lines
21 KiB
Go

package services
import (
"context"
"encoding/binary"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
"pvfine/internal/pvf"
)
// BenchmarkAdvancedIndexBuild measures a cold build without query or cache reuse.
func BenchmarkAdvancedIndexBuild(b *testing.B) {
a := pvf.New()
var script strings.Builder
for i := 0; i < 64; i++ {
fmt.Fprintf(&script, "{7=`value-%d`}\n", i)
}
for i := 0; i < 4096; i++ {
if _, err := a.AddFileText(fmt.Sprintf("dir/%d.equ", i), script.String(), pvf.TypeScript); err != nil {
b.Fatal(err)
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx, cancel := context.WithCancel(context.Background())
d := &advancedSQLite{ctx: ctx, cancel: cancel, closed: make(chan struct{})}
c := &core{archive: a, advancedDisk: d}
err := d.build(c, a)
d.close()
if err != nil {
b.Fatal(err)
}
}
}
func TestAdvancedSQLiteBatchesAcrossTransactions(t *testing.T) {
a := pvf.New()
raw := make([]byte, 70*10)
for i := 0; i < 70; i++ {
offset := a.StringOffset(fmt.Sprintf("value-%d", i))
raw[i*10], raw[i*10+5] = 7, 3
binary.LittleEndian.PutUint32(raw[i*10+1:], uint32(offset))
binary.LittleEndian.PutUint32(raw[i*10+6:], uint32(offset))
}
for i := 0; i < 300; i++ {
a.AddFile(fmt.Sprintf("dir/%d.equ", i), raw, pvf.TypeScript)
}
ctx, cancel := context.WithCancel(context.Background())
d := &advancedSQLite{ctx: ctx, cancel: cancel, closed: make(chan struct{})}
defer d.close()
c := &core{archive: a, advancedDisk: d}
if err := d.build(c, a); err != nil {
t.Fatal(err)
}
// Each of four shards crosses its 4,096-row transaction boundary and
// leaves a partial final batch. Check every file's details across shards.
if err := d.attachShards(ctx, d.db); err != nil {
t.Fatal(err)
}
var parts []string
for shard := 0; shard < d.shards; shard++ {
parts = append(parts, fmt.Sprintf("SELECT * FROM refs%d.refs", shard))
}
rows, err := d.db.Query(`SELECT file_index,count(*),sum(occurrences),
sum(fields=0 AND occurrences=2 AND types=55),sum(fields)
FROM (` + strings.Join(parts, " UNION ALL ") + `) GROUP BY file_index ORDER BY file_index`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
files := 0
for rows.Next() {
var index, count, occurrences, tokens, fields int
if err := rows.Scan(&index, &count, &occurrences, &tokens, &fields); err != nil {
t.Fatal(err)
}
if index != files || count != 72 || occurrences != 142 || tokens != 70 || fields != 3 {
t.Fatalf("file %d: refs=%d occurrences=%d tokens=%d fields=%d", index, count, occurrences, tokens, fields)
}
files++
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if files != 300 {
t.Fatalf("indexed %d files, want 300", files)
}
}
// BenchmarkAdvancedIndexRealArchive opts in via PVF_TESTFILE. It measures only
// cold index construction, without opening or querying. Cache restore is timed
// separately using temporary hard links, never the application's reusable cache.
func BenchmarkAdvancedIndexRealArchive(b *testing.B) {
benchmarkAdvancedIndexReal(b, (*advancedSQLite).build)
}
func benchmarkAdvancedIndexReal(b *testing.B, build func(*advancedSQLite, *core, *pvf.Archive) error) {
path := os.Getenv("PVF_TESTFILE")
if path == "" {
b.Skip("PVF_TESTFILE required")
}
a, err := pvf.Open(path)
if err != nil {
b.Fatal("cannot open benchmark archive")
}
// An unused, in-memory pool entry marks the archive dirty and prevents both
// restoring and publishing an index cache. Never save this benchmark archive.
for i := 0; !a.Modified(); i++ {
a.StringOffset(fmt.Sprintf("__pvfine_cold_index_benchmark_%d__", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx, cancel := context.WithCancel(context.Background())
d := &advancedSQLite{ctx: ctx, cancel: cancel, closed: make(chan struct{})}
c := &core{archive: a, advancedDisk: d}
err := build(d, c, a)
b.StopTimer()
if err != nil {
d.close()
b.Fatal(err)
}
root := b.TempDir()
d.cachePath = filepath.Join(root, "cache")
if err := d.publishCache(root); err != nil {
d.close()
b.Fatal(err)
}
d.close()
ctx, cancel = context.WithCancel(context.Background())
warm := &advancedSQLite{ctx: ctx, cancel: cancel, dir: b.TempDir(), cachePath: d.cachePath, closed: make(chan struct{})}
started := time.Now()
ok := warm.restoreCache()
b.ReportMetric(float64(time.Since(started).Microseconds())/1000, "cache-ms")
warm.close()
if !ok {
b.Fatal("real archive cache could not be restored")
}
b.StartTimer()
}
}
func BenchmarkAdvancedReferenceBatchSizes(b *testing.B) {
for _, size := range []int{1, 8, 16, 32, 128} {
b.Run(fmt.Sprint(size), func(b *testing.B) {
for n := 0; n < b.N; n++ {
b.StopTimer()
db, err := openAdvancedDB(filepath.Join(b.TempDir(), "refs.db"))
if err != nil {
b.Fatal(err)
}
if _, err = db.Exec(`CREATE TABLE refs(file_index INTEGER,offset INTEGER,occurrences INTEGER,types INTEGER,fields INTEGER,PRIMARY KEY(file_index,offset)) WITHOUT ROWID`); err != nil {
b.Fatal(err)
}
tx, err := db.Begin()
if err != nil {
b.Fatal(err)
}
stmt, err := tx.Prepare(`INSERT INTO refs VALUES` + strings.TrimSuffix(strings.Repeat("(?,?,?,?,?),", size), ","))
if err != nil {
b.Fatal(err)
}
args := make([]any, size*5)
b.StartTimer()
for row := 0; row < 65536; row += size {
for i := 0; i < size; i++ {
args[i*5], args[i*5+1] = (row+i)/64, (row+i)%64
args[i*5+2], args[i*5+3], args[i*5+4] = 1, 7, 0
}
if _, err = stmt.ExecContext(context.Background(), args...); err != nil {
b.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
b.Fatal(err)
}
b.StopTimer()
stmt.Close()
db.Close()
}
})
}
}
func advancedFixture(t *testing.T) (*core, *ArchiveService) {
t.Helper()
a := pvf.New()
for i, text := range []string{"[name]\n`共同 Alpha`\n{3=`共同 Alpha`}\n{5=`重复`}\n{7=`重复`}", "[name]\n`共同 beta`", "[name]\n`共同 Gamma`", "[name]\n`中文 标点.*?`"} {
if _, err := a.AddFileText(fmt.Sprintf("dir/%d.equ", i), text, pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
if _, err := a.AddFileText("other/4.equ", "[name]\n`共同 outside`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
c := NewCore()
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
c.mu.RLock()
d := c.advancedDisk
c.mu.RUnlock()
c.closeArchive()
if d != nil {
select {
case <-d.closed:
case <-time.After(5 * time.Second):
t.Error("session cleanup timed out")
}
}
})
return c, NewArchiveService(c)
}
func TestAdvancedSearchFindsLocalized110USNames(t *testing.T) {
for _, disk := range []bool{false, true} {
t.Run(fmt.Sprint("disk=", disk), func(t *testing.T) {
c, a, itemIndex, _ := paged110LayoutFixture(t)
if disk {
index, _, err := openSQLiteArchiveIndex(a)
if err != nil {
t.Fatal(err)
}
c.mu.Lock()
c.diskIndex = index
c.installDiskArchiveIndexesLocked(a)
c.mu.Unlock()
}
c.startSearchIndex()
waitForSearchIndex(t, c)
for _, regex := range []bool{false, true} {
query := "白色兽语腰带"
if regex {
query = "^白色兽语"
}
result, err := NewArchiveService(c).AdvancedSearch("string", query, "equipment/character/x", regex, 0, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) != 1 || result.Hits[0].FileIndex != itemIndex {
t.Fatalf("localized results = %#v", result.Hits)
}
found := false
for _, detail := range result.Hits[0].Details {
if detail.Kind == "string" && detail.Value == "白色兽语腰带 [A款]" && reflect.DeepEqual(detail.TokenTypes, []int32{8}) {
found = true
}
}
if !found {
t.Fatalf("localized details = %#v", result.Hits[0].Details)
}
}
})
}
}
func TestAdvancedSearchFindsLocalized110USDescriptions(t *testing.T) {
for _, disk := range []bool{false, true} {
t.Run(fmt.Sprint("disk=", disk), func(t *testing.T) {
a := pvf.New()
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.uv.str`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
encode := func(value string) []byte {
out := make([]byte, 0, len(value)*2)
for _, r := range value {
out = append(out, byte(r), byte(r>>8))
}
return out
}
a.AddFile("String/Equipment.uv.str", encode("name_1>测试装备\r\ndesc_1>技能伤害增加 10%\r\n"), pvf.TypeScript)
if _, err := a.AddFileText("list/equipment.lst", "1 `equipment/test.equ`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
itemIndex, err := a.AddFileText("equipment/test.equ", "[name]\n{8=`<3::name_1>`}\n[basic explain]\n{10=`<3::desc_1>`}", pvf.TypeScript)
if err != nil {
t.Fatal(err)
}
c := NewCore()
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
t.Cleanup(c.closeArchive)
if disk {
index, _, err := openSQLiteArchiveIndex(a)
if err != nil {
t.Fatal(err)
}
c.mu.Lock()
c.diskIndex = index
c.installDiskArchiveIndexesLocked(a)
c.mu.Unlock()
}
c.startSearchIndex()
waitForSearchIndex(t, c)
for _, query := range []string{"技能伤害", "^技能伤害增加"} {
result, err := NewArchiveService(c).AdvancedSearch("string", query, "equipment", strings.HasPrefix(query, "^"), 0, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Hits) != 1 || result.Hits[0].FileIndex != itemIndex {
t.Fatalf("description search %q = %#v", query, result.Hits)
}
found := false
for _, detail := range result.Hits[0].Details {
if detail.Kind == "string" && detail.Value == "技能伤害增加 10%" && reflect.DeepEqual(detail.TokenTypes, []int32{10}) {
found = true
}
}
if !found {
t.Fatalf("description details %q = %#v", query, result.Hits[0].Details)
}
}
})
}
}
func TestSQLiteFileURIUsesLocalFileForm(t *testing.T) {
path := filepath.Join(t.TempDir(), "index.db")
u, err := url.Parse(sqliteFileURI(path, "mode=ro"))
if err != nil {
t.Fatal(err)
}
if u.Scheme != "file" || u.Host != "" {
t.Fatalf("unexpected local file URI: %q", u.String())
}
if u.Query().Get("mode") != "ro" {
t.Fatalf("missing read-only query: %q", u.String())
}
}
func TestAdvancedSQLiteMatchesMemoryOracle(t *testing.T) {
c, svc := advancedFixture(t)
oracle, err := c.archive.BuildStringPoolIndex(context.Background())
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
query, scope string
regex bool
}{
{"共同", "", false}, {"ALPHA", "DIR", false}, {"中文", "", false}, {".*?", "", false},
{"^共同.*[aA]$", "dir", true}, {"重复", "", false}, {"dir", "", false}, {"0.equ", "", false}, {"absent", "", false},
} {
t.Run(tc.query+tc.scope, func(t *testing.T) {
want, err := oracle.MatchDetails(tc.query, tc.regex)
if err != nil {
t.Fatal(err)
}
filtered := want[:0]
for _, m := range want {
if advancedPathInScope(c.archive.Path(m.FileIndex), normalizeAdvancedScope(tc.scope)) {
filtered = append(filtered, m)
}
}
want = filtered
var got []pvf.StringPoolMatch
cursor := 0
seen := map[int32]bool{}
for {
result, err := svc.AdvancedSearch("string", tc.query, tc.scope, tc.regex, cursor, 1)
if err != nil {
t.Fatal(err)
}
for _, hit := range result.Hits {
if seen[hit.FileIndex] {
t.Fatal("duplicate file across pages")
}
seen[hit.FileIndex] = true
for _, d := range hit.Details {
got = append(got, pvf.StringPoolMatch{FileIndex: hit.FileIndex, Pool: d.Pool, Offset: d.PoolOffset, Value: d.Value, Occurrences: d.Occurrences, TokenTypes: d.TokenTypes, FileFields: d.FileFields})
}
}
if result.NextCursor < 0 {
break
}
if result.NextCursor == cursor {
t.Fatal("cursor did not advance")
}
cursor = result.NextCursor
}
sort.Slice(got, func(i, j int) bool {
if got[i].FileIndex != got[j].FileIndex {
return got[i].FileIndex < got[j].FileIndex
}
return got[i].Offset < got[j].Offset
})
if len(got) != len(want) {
t.Fatalf("got %d details, want %d", len(got), len(want))
}
for i := range got {
if !reflect.DeepEqual(got[i], want[i]) {
t.Fatalf("detail mismatch: got %#v want %#v", got[i], want[i])
}
}
})
}
}
func TestAdvancedSQLiteCursorCacheAndInvalidation(t *testing.T) {
c, svc := advancedFixture(t)
first, err := svc.AdvancedSearch("string", "共同", "", false, 0, 1)
if err != nil {
t.Fatal(err)
}
d := c.advancedDisk
if len(d.queries) != 1 {
t.Fatal("query not cached")
}
_, err = svc.AdvancedSearch("string", "共同", "dir", false, first.NextCursor, 1)
if !errors.Is(err, ErrAdvancedCursorStale) {
t.Fatalf("changed query accepted cursor: %v", err)
}
for i := 0; i < 2; i++ {
if _, err = svc.AdvancedSearch("string", "共同", "", false, first.NextCursor, 1); err != nil {
t.Fatal(err)
}
}
if len(d.queries) != 1 {
t.Fatal("paging built a new query")
}
// Modify the raw archive under the core lock without invalidating on purpose:
// an existing result page must be served exclusively from the SQL snapshot.
c.mu.Lock()
err = c.archive.SetText(1, "[name]\n`replaced`")
c.mu.Unlock()
if err != nil {
t.Fatal(err)
}
next, err := svc.AdvancedSearch("string", "共同", "", false, first.NextCursor, 1)
if err != nil || len(next.Hits) != 1 || next.Hits[0].Details[0].Value != "共同 beta" {
t.Fatalf("page rescanned archive: %#v %v", next, err)
}
c.mu.Lock()
c.invalidateAdvancedSearchLocked()
c.mu.Unlock()
if _, err = svc.AdvancedSearch("string", "共同", "", false, first.NextCursor, 1); !errors.Is(err, ErrAdvancedCursorStale) {
t.Fatalf("stale cursor accepted: %v", err)
}
select {
case <-d.closed:
case <-time.After(5 * time.Second):
t.Fatal("cleanup blocked")
}
if _, err = os.Stat(d.dir); !os.IsNotExist(err) {
t.Fatalf("session leaked: %v", err)
}
result, err := svc.AdvancedSearch("string", "replaced", "", false, 0, 20)
if err != nil || len(result.Hits) != 1 {
t.Fatalf("rebuild after edit: %#v %v", result, err)
}
}
func TestAdvancedSQLiteEvictsQueries(t *testing.T) {
c, svc := advancedFixture(t)
first, err := svc.AdvancedSearch("string", "共同", "", false, 0, 1)
if err != nil {
t.Fatal(err)
}
var oldPath string
for _, q := range c.advancedDisk.queries {
oldPath = q.path
}
for i := 0; i < advancedQueryLimit; i++ {
if _, err = svc.AdvancedSearch("string", fmt.Sprintf("missing-%d", i), "", false, 0, 1); err != nil {
t.Fatal(err)
}
}
if len(c.advancedDisk.queries) != advancedQueryLimit {
t.Fatal("wrong query cache size")
}
if _, err = os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatal("evicted result file still exists")
}
if _, err = svc.AdvancedSearch("string", "共同", "", false, first.NextCursor, 1); !errors.Is(err, ErrAdvancedCursorStale) {
t.Fatalf("evicted cursor accepted: %v", err)
}
}
func TestAdvancedSQLiteCancelWhileBuilding(t *testing.T) {
c, svc := advancedFixture(t)
// Hold the operation lock so the request is active but cannot start its SQL
// work. Cancel must not wait for this lock while holding core.mu.
ctx, cancel := context.WithCancel(context.Background())
d := &advancedSQLite{ctx: ctx, cancel: cancel, queries: map[uint64]*advancedDiskQuery{}, closed: make(chan struct{})}
c.mu.Lock()
c.advancedDisk = d
c.advancedCancel = cancel
c.mu.Unlock()
d.mu.Lock()
done := make(chan struct{})
go func() { svc.CancelAdvancedSearch(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
d.mu.Unlock()
t.Fatal("cancel blocked on operation lock")
}
if d.ctx.Err() != context.Canceled {
t.Fatal("operation context not cancelled")
}
d.mu.Unlock()
select {
case <-d.closed:
case <-time.After(5 * time.Second):
t.Fatal("cancel cleanup deadlocked")
}
}
func TestAdvancedSQLiteSavedCacheAndCorruption(t *testing.T) {
a := pvf.New()
for i := 0; i < 4; i++ {
if _, err := a.AddFileText(fmt.Sprintf("unique/%d-%d.equ", time.Now().UnixNano(), i), "[name]\n`cached value`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
path := t.TempDir() + "/cache.pvf"
if err := a.SaveAs(path); err != nil {
t.Fatal(err)
}
open := func() (*core, *ArchiveService) {
archive, err := pvf.Open(path)
if err != nil {
t.Fatal(err)
}
c := NewCore()
if err = c.setArchive(archive); err != nil {
t.Fatal(err)
}
return c, NewArchiveService(c)
}
c, svc := open()
if _, err := svc.AdvancedSearch("string", "cached value", "", false, 0, 1); err != nil {
t.Fatal(err)
}
d := c.advancedDisk
cache := d.cachePath
if cache == "" {
t.Fatal("clean archive not cached")
}
defer os.RemoveAll(cache)
c.closeArchive()
<-d.closed
if _, err := os.Stat(cache); err != nil {
t.Fatalf("clean cache disappeared: %v", err)
}
c, svc = open()
if _, err := svc.AdvancedSearch("string", "cached value", "", false, 0, 1); err != nil {
t.Fatal(err)
}
if svc.AdvancedIndexStatus().Stage != "ready-cache" {
t.Fatal("clean index not reused")
}
d = c.advancedDisk
c.closeArchive()
<-d.closed
if err := os.WriteFile(filepath.Join(cache, "index.db"), []byte("corrupt derived cache"), 0600); err != nil {
t.Fatal(err)
}
c, svc = open()
if _, err := svc.AdvancedSearch("string", "cached value", "", false, 0, 1); err != nil {
t.Fatal(err)
}
if svc.AdvancedIndexStatus().Stage != "ready-sqlite" {
t.Fatal("corrupt cache not rebuilt")
}
for _, missing := range []bool{true, false} {
d = c.advancedDisk
c.closeArchive()
<-d.closed
shard := filepath.Join(cache, advancedShardName(0))
var err error
if missing {
err = os.Remove(shard)
} else {
err = os.WriteFile(shard, []byte("corrupt shard"), 0600)
}
if err != nil {
t.Fatal(err)
}
c, svc = open()
result, err := svc.AdvancedSearch("string", "cached value", "", false, 0, 10)
if err != nil || len(result.Hits) != 4 || svc.AdvancedIndexStatus().Stage != "ready-sqlite" {
t.Fatalf("damaged shard was not rebuilt: %v", err)
}
}
d = c.advancedDisk
if err := NewEditorService(c).SetText(0, "[name]\n`unsaved value`"); err != nil {
t.Fatal(err)
}
if c.advancedDisk != d {
t.Fatal("editing discarded the ready reference base")
}
if _, err := svc.AdvancedSearch("string", "unsaved value", "", false, 0, 1); err != nil {
t.Fatal(err)
}
d = c.advancedDisk
if d.cachePath != "" {
t.Fatal("unsaved index published as clean")
}
c.closeArchive()
<-d.closed
}
func TestAdvancedSQLiteResultDiskLimit(t *testing.T) {
c, svc := advancedFixture(t)
if _, err := svc.AdvancedSearch("string", "共同", "", false, 0, 1); err != nil {
t.Fatal(err)
}
d := c.advancedDisk
d.mu.Lock()
for len(d.queries) > 0 {
if err := d.evictOldest(); err != nil {
d.mu.Unlock()
t.Fatal(err)
}
}
d.queryBudget = 8192
d.mu.Unlock()
if _, err := svc.AdvancedSearch("string", "different query", "", false, 0, 1); err == nil {
t.Fatal("disk limit not enforced")
}
if len(d.queries) != 0 {
t.Fatal("failed query was published")
}
entries, err := os.ReadDir(d.dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1+d.shards {
t.Fatalf("failed query leaked files: %v", entries)
}
for _, entry := range entries {
if entry.Name() != "index.db" && !strings.HasPrefix(entry.Name(), "refs-") {
t.Fatalf("failed query leaked a result file: %s", entry.Name())
}
}
// Read/edit source data remains available after a derived query failure.
c.mu.RLock()
text, err := c.archive.Text(0)
c.mu.RUnlock()
if err != nil || text == "" {
t.Fatal("source unavailable after disk failure")
}
}
func TestAdvancedSQLiteCloseDuringScan(t *testing.T) {
a := pvf.New()
for i := 0; i < 20000; i++ {
if _, err := a.AddFileText(fmt.Sprintf("dir/%d.equ", i), "[name]\n`shared`", pvf.TypeScript); err != nil {
t.Fatal(err)
}
}
c := NewCore()
if err := c.setArchive(a); err != nil {
t.Fatal(err)
}
svc := NewArchiveService(c)
result := make(chan error, 1)
go func() { _, err := svc.AdvancedSearch("string", "shared", "", false, 0, 20); result <- err }()
deadline := time.Now().Add(5 * time.Second)
var d *advancedSQLite
for time.Now().Before(deadline) {
c.mu.RLock()
d = c.advancedDisk
building := c.advancedStatus.State == AdvancedIndexStateBuilding
c.mu.RUnlock()
if d != nil && building {
break
}
time.Sleep(time.Millisecond)
}
if d == nil {
c.closeArchive()
t.Fatal("build did not start")
}
start := time.Now()
c.closeArchive()
if time.Since(start) > time.Second {
t.Error("close held up by scan")
}
select {
case err := <-result:
if err == nil {
t.Fatal("closed build published results")
}
case <-time.After(5 * time.Second):
t.Fatal("scan did not cancel")
}
select {
case <-d.closed:
case <-time.After(5 * time.Second):
t.Fatal("scan cleanup blocked")
}
if _, err := os.Stat(d.dir); !os.IsNotExist(err) {
t.Fatal("cancelled build leaked files")
}
}