Add adapter interaction contracts (T-608)
Harness governance / validate (push) Has been cancelled
Phase 0 build gate / verify (push) Has been cancelled

This commit is contained in:
ila
2026-07-17 10:41:40 +08:00
parent 0705948d74
commit 1b7f72e658
8 changed files with 562 additions and 14 deletions
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1080, 720)
adapterContractCompactViewport = image.Pt(1080, 420)
)
const adapterContractEdition = "Modern"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
t.Fatal("empty catalog did not render the catalog-unavailable state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+270
View File
@@ -0,0 +1,270 @@
package gio
import (
"fmt"
"image"
"testing"
"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"softbox.local/core/application"
"softbox.local/core/domain"
)
var (
adapterContractViewport = image.Pt(1024, 680)
adapterContractCompactViewport = image.Pt(1024, 380)
)
const adapterContractEdition = "Legacy"
func TestAdapterContractInputEventsUpdateModel(t *testing.T) {
shell := NewAppShell(adapterContractEdition, adapterContractItems()...)
shell.search.SetText("APP-TWO")
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Query(); got != "app-two" {
t.Fatalf("query after editor update = %q, want app-two", got)
}
visible := shell.model.VisibleItems()
if len(visible) != 1 || visible[0].ID != "app-two" {
t.Fatalf("visible IDs after editor update = %v, want [app-two]", adapterContractIDs(visible))
}
shell.search.SetText("")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.Category(); got != "图像" {
t.Fatalf("category after click = %q, want 图像", got)
}
shell.viewUpdates.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewUpdates {
t.Fatalf("view after updates click = %q", got)
}
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewInstalled {
t.Fatalf("view after installed click = %q", got)
}
shell.viewAll.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.View(); got != application.CatalogViewAll {
t.Fatalf("view after all click = %q", got)
}
shell.search.SetText("missing-app")
nodes := adapterContractLayout(shell, adapterContractViewport)
if !adapterContractHasSemantic(nodes, "没有匹配的软件") ||
!adapterContractHasSemantic(nodes, "显示全部软件") {
t.Fatal("filtered empty state did not expose its recovery action")
}
shell.resetFilters.Click()
adapterContractLayout(shell, adapterContractViewport)
if shell.search.Text() != "" || shell.model.Query() != "" ||
shell.model.Category() != "" || shell.model.View() != application.CatalogViewAll {
t.Fatalf(
"reset state = editor %q, query %q, category %q, view %q",
shell.search.Text(), shell.model.Query(), shell.model.Category(), shell.model.View(),
)
}
if got := len(shell.model.VisibleItems()); got != len(adapterContractItems()) {
t.Fatalf("visible count after reset = %d, want %d", got, len(adapterContractItems()))
}
}
func TestAdapterContractRowIdentityAndDetailContext(t *testing.T) {
items := adapterContractLargeCatalog(80)
shell := NewAppShell(adapterContractEdition, items...)
targetID := "app-037"
targetControl := shell.rows[targetID]
reordered := append([]application.CatalogListItem(nil), items...)
for left, right := 0, len(reordered)-1; left < right; left, right = left+1, right-1 {
reordered[left], reordered[right] = reordered[right], reordered[left]
}
shell.SetItems(reordered)
if shell.rows[targetID] != targetControl {
t.Fatal("row control was recreated after catalog reorder")
}
shell.search.SetText("app-")
adapterContractLayout(shell, adapterContractViewport)
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractViewport)
shell.viewInstalled.Click()
adapterContractLayout(shell, adapterContractViewport)
shell.appList.ScrollTo(12)
adapterContractLayout(shell, adapterContractViewport)
targetControl.open.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != targetID {
t.Fatalf("selected ID after reordered row click = %q, want %q", got, targetID)
}
if !shell.detailRendered {
t.Fatal("row click selected the model but did not render detail")
}
queryBefore := shell.model.Query()
categoryBefore := shell.model.Category()
viewBefore := shell.model.View()
positionBefore := shell.appList.Position
shell.closeDetail.Click()
adapterContractLayout(shell, adapterContractViewport)
if got := shell.model.SelectedID(); got != "" {
t.Fatalf("selected ID after close = %q, want empty", got)
}
if shell.detailRendered {
t.Fatal("detail remained rendered after close click")
}
if shell.model.Query() != queryBefore || shell.model.Category() != categoryBefore ||
shell.model.View() != viewBefore {
t.Fatal("closing detail changed the active list filters")
}
positionAfter := shell.appList.Position
if positionAfter.First != positionBefore.First || positionAfter.Offset != positionBefore.Offset {
t.Fatalf(
"list position after close = first %d offset %d, want first %d offset %d",
positionAfter.First, positionAfter.Offset, positionBefore.First, positionBefore.Offset,
)
}
}
func TestAdapterContractVirtualizationAndControlLifecycle(t *testing.T) {
items := adapterContractLargeCatalog(500)
shell := NewAppShell(adapterContractEdition, items...)
retainedRow := shell.rows["app-001"]
retainedCategory := shell.categoryControls["图像"]
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.lastRendered <= 0 || shell.lastRendered >= len(items) {
t.Fatalf("lastRendered = %d, want a non-zero subset of %d", shell.lastRendered, len(items))
}
if count := shell.appList.Position.Count; count <= 0 || count >= len(items) {
t.Fatalf("layout.List visible count = %d, want a non-zero subset of %d", count, len(items))
}
shell.categoryControls["图像"].Click()
adapterContractLayout(shell, adapterContractCompactViewport)
if shell.rows["app-001"] != retainedRow || shell.categoryControls["图像"] != retainedCategory {
t.Fatal("filtering recreated stable app or category controls")
}
shell.SetItems([]application.CatalogListItem{items[3], items[1]})
if shell.rows["app-001"] != retainedRow {
t.Fatal("retained app lost its row control after snapshot update")
}
if shell.categoryControls["图像"] != retainedCategory {
t.Fatal("retained category lost its control after snapshot update")
}
if _, exists := shell.rows["app-000"]; exists {
t.Fatal("removed app retained its row control")
}
if _, exists := shell.categoryControls["工具"]; exists {
t.Fatal("removed category retained its control")
}
}
func TestAdapterContractDistinguishesEmptyCatalogAndNoMatches(t *testing.T) {
emptyShell := NewAppShell(adapterContractEdition)
emptyNodes := adapterContractLayout(emptyShell, adapterContractViewport)
if !adapterContractHasSemantic(emptyNodes, "软件目录尚未加载") {
t.Fatal("empty catalog did not render the catalog-unavailable state")
}
if adapterContractHasSemantic(emptyNodes, "显示全部软件") {
t.Fatal("empty catalog rendered a filter recovery action")
}
filteredShell := NewAppShell(adapterContractEdition, adapterContractItems()...)
filteredShell.search.SetText("missing-app")
filteredNodes := adapterContractLayout(filteredShell, adapterContractViewport)
if !adapterContractHasSemantic(filteredNodes, "没有匹配的软件") {
t.Fatal("filtered catalog did not render the no-matches state")
}
if !adapterContractHasSemantic(filteredNodes, "显示全部软件") {
t.Fatal("filtered catalog did not render its recovery action")
}
filteredShell.resetFilters.Click()
adapterContractLayout(filteredShell, adapterContractViewport)
if filteredShell.search.Text() != "" || filteredShell.model.Query() != "" {
t.Fatal("filter recovery did not clear editor and model query")
}
if len(filteredShell.model.VisibleItems()) == 0 {
t.Fatal("filter recovery did not restore catalog rows")
}
}
func adapterContractItems() []application.CatalogListItem {
return []application.CatalogListItem{
{
ID: "app-one", Name: "One", Version: "1.0.0", Category: "工具",
Status: domain.StatusNotInstalled, Installable: true,
},
{
ID: "app-two", Name: "Two", Version: "1.0.0", Category: "图像",
Status: domain.StatusInstalled, Installed: true, Installable: true,
},
{
ID: "app-three", Name: "Three", Version: "2.0.0", Category: "图像",
Status: domain.StatusUpdateAvailable, Installed: true, Installable: true,
},
}
}
func adapterContractLargeCatalog(count int) []application.CatalogListItem {
items := make([]application.CatalogListItem, count)
for index := range items {
category := "工具"
if index%2 == 1 {
category = "图像"
}
items[index] = application.CatalogListItem{
ID: fmt.Sprintf("app-%03d", index),
Name: fmt.Sprintf("App %03d", index),
Version: "1.0.0",
Category: category,
Status: domain.StatusInstalled,
Installed: true,
Installable: true,
}
}
return items
}
func adapterContractLayout(shell *AppShell, size image.Point) []input.SemanticNode {
var operations op.Ops
var router input.Router
context := layout.Context{
Ops: &operations,
Source: router.Source(),
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
Constraints: layout.Exact(size),
}
shell.Layout(context, NewTheme())
router.Frame(&operations)
return router.AppendSemantics(nil)
}
func adapterContractHasSemantic(nodes []input.SemanticNode, want string) bool {
for _, node := range nodes {
if node.Desc.Label == want || node.Desc.Description == want ||
adapterContractHasSemantic(node.Children, want) {
return true
}
}
return false
}
func adapterContractIDs(items []application.CatalogListItem) []string {
ids := make([]string, len(items))
for index, item := range items {
ids[index] = item.ID
}
return ids
}
+2 -2
View File
@@ -45,7 +45,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
## 当前阶段 ## 当前阶段
当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-607`。Windows 安全路径阻断项、图标缓存资源边界与后台结果回 UI 线程的事件接线均已关闭;T-608 已落成待领取,下一步建立 modern/win7 适配器交互契约,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。 当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-608`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线与双适配器交互契约均已关闭;下一步按 Phase 2 交叉审核顺序落成 `VisibleItems` 快照生命周期任务,其余审核整改与 Phase 1 中央目录预扫描继续串行处理,T-302 暂后置。
优先路径: 优先路径:
@@ -53,7 +53,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
5. 已完成 T-606/T-607:图标缓存按 key 去重、流式有界读取、memory LRU、双 shell 图标剪枝,以及 Load/Decode→application event→有界 relay/Invalidate→UI ApplyEvent 的线程接线;下一步实现 T-608 双适配器交互契约,再串行处理其余整改与 T-302/T-303、Phase 4-6。 5. 已完成 T-606/T-607/T-608:图标缓存资源边界、Load/Decode→application event→有界 relay/Invalidate→UI ApplyEvent 线程接线,以及双 Gio 适配器的 Editor/Clickable、AppID、viewport、详情上下文与控件生命周期契约;下一步落成 `VisibleItems` 快照任务,再串行处理其余整改与 T-302/T-303、Phase 4-6。
## 领取任务规则 ## 领取任务规则
+1 -1
View File
@@ -59,7 +59,7 @@ UI 固定交互模式:
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。 控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;500 项 viewport 测试验证只布局可见行。主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。 T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。
T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。 T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。
+6 -6
View File
@@ -13,22 +13,22 @@
## 当前快照 ## 当前快照
- 日期:2026-07-17 - 日期:2026-07-17
- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-607 已完成,T-608 已落成待领取,T-302 继续暂后置 - 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-608 已完成,T-302 继续暂后置
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon - 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型、无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、图标请求身份与 UI-only ApplyEvent/ApplyIcon
- 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 500 项虚拟列表、ID 控件/图标剪枝稳定性、详情、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过 - 测试:core 覆盖 Catalog、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性、列表/图标并发/取消/读取边界/LRU、图标事件身份/失败分类/relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、最新/取消/换引用图标结果与平台 stub;安装恢复矩阵保持通过
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
- 当前 blocker:无;下一步领取 T-608,建立 modern/win7 Gio 适配器交互契约;VisibleItems 快照、Phase 1 中央目录预扫描等继续串行,T-302 继续后置 - 当前 blocker:无;下一步按 `docs/review/phase2-review.md` 最终顺序落成 `VisibleItems` 快照生命周期任务;Phase 1 中央目录预扫描等继续串行,T-302 继续后置
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-607 已完成;T-608 已落成待领取,其余审核整改尚未编号,T-302 暂后置 | | `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-608 已完成;其余审核整改尚未编号,T-302 暂后置 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp |
@@ -40,9 +40,9 @@
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-607`。 - 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-608`。
- 正在进行:无。 - 正在进行:无。
- 下一个可领取任务:`T-608`(建立双 Gio 适配器交互契约),依赖 `T-607` 已完成。 - 下一个可领取任务:无;先按 `docs/review/phase2-review.md` 最终顺序落成 `VisibleItems` 快照生命周期任务。
## 当前可运行内容 ## 当前可运行内容
+2 -2
View File
@@ -285,5 +285,5 @@ modern/win7 的 `ApplyIcon` 都直接写 `shell.icons` map,Layout 同时读取
- `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。 - `T-606` 已完成最终处理顺序第 1 项:按 key in-flight 去重、Fetcher 流式有界读取、32 MiB/256-key memory LRU 与 modern/win7 `shell.icons` 剪枝已实现并通过完整双 workspace 闸门。
- `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。 - `T-607` 已完成最终处理顺序第 2 项:后台图标 Load/Decode 只发布强类型 application event,有界 FIFO relay 无损背压并请求重绘,由 Gio Frame/UI goroutine drain 后执行 `ApplyEvent`/`ApplyIcon`;最新请求、删除 app、IconRef/DPI 变化和取消都阻断迟到结果回写。core 与双 workspace 定向测试及完整闸门通过。
- `T-608` 已按最终处理顺序第 3 项落成待领取:modern/win7 使用同场景矩阵验证 Editor/Clickable 接线、AppID 行身份、viewport、详情上下文与控件释放,不重复共享 ViewModel 纯逻辑。 - `T-608` 已完成最终处理顺序第 3 项:modern/win7 使用除 edition/窗口尺寸外一致的场景矩阵,验证 Editor/Clickable 经 Layout 更新共享 model、重排后 AppID 行身份、详情关闭上下文、500 项 viewport、app/category controls 释放及两类空状态语义;双端定向重复测试与完整闸门通过,生产 `shell.go` 无需修正。
- `VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;必须等待 T-608 完成并提交后再按顺序落成。 - `VisibleItems` 快照、`shell.go` 拆分和 unsafe cache 诊断尚未编号;下一任务从 `VisibleItems` 快照生命周期开始,继续按顺序串行落成。
+2
View File
@@ -61,6 +61,8 @@ T-203 已落地的列表交互约束:
- 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。 - 行 Clickable 保存在以 app ID 为键的 map;筛选、视图切换或 Catalog 快照更新不按可见序号迁移控件状态。
- 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。 - 无 Catalog 与过滤无结果是两个不同空状态;后者提供“显示全部软件”恢复操作。
T-608 用 modern/win7 同场景适配器契约固定上述接线:Editor 和视图/分类/行/恢复/关闭 Clickable 必须经 `Layout`/`drainInput` 更新共享 model;Catalog 重排后行事件仍按 AppID 选择,关闭详情保留筛选与 list First/Offset。500 项有限 viewport 同时核对实际布局计数与 `layout.List.Position.Count`;空 Catalog、过滤无结果及恢复按钮通过 Gio 语义树区分。两端测试只在 edition/窗口尺寸上保留真实差异,不互相 import Gio。
T-204 已落地的详情/图标约束: T-204 已落地的详情/图标约束:
- 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。 - 点击软件行用 selected app ID 打开右侧详情,关闭后回到同一列表/筛选/滚动上下文。
+9 -3
View File
@@ -3,12 +3,12 @@ id: T-608
title: 建立双 Gio 适配器交互契约 title: 建立双 Gio 适配器交互契约
phase: 2 phase: 2
deps: [T-607] deps: [T-607]
status: TODO status: DONE
created: 2026-07-17 created: 2026-07-17
issue: null issue: null
context_ref: null context_ref: 0705948d749722af5814ee91267cb44db6c8164c
claim_branch: null claim_branch: null
work_branch: null work_branch: agent/codex/T-608
write_paths: write_paths:
- docs/tasks/T-608.md - docs/tasks/T-608.md
- app-modern/ui/gio/ - app-modern/ui/gio/
@@ -84,3 +84,9 @@ T-203 已把筛选、可见项、选中项和视图切换的核心语义收敛
- 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第三优先级整改落成任务;现有全局最大任务为 T-607,因此取 T-608,依赖已完成的 T-607。 - 2026-07-17:根据 `docs/review/phase2-review.md` 交叉复核定稿的第三优先级整改落成任务;现有全局最大任务为 T-607,因此取 T-608,依赖已完成的 T-607。
- 2026-07-17:代码图核对显示两个 `drainInput` 都负责 Editor、视图、分类、行、恢复和关闭控件接线,但现有 shell 测试主要直接调用 model setter;任务据此冻结“验证适配器接线而非重复 ViewModel 纯逻辑”的范围。 - 2026-07-17:代码图核对显示两个 `drainInput` 都负责 Editor、视图、分类、行、恢复和关闭控件接线,但现有 shell 测试主要直接调用 model setter;任务据此冻结“验证适配器接线而非重复 ViewModel 纯逻辑”的范围。
- 2026-07-17:本地 `go doc` 确认 modern Gio v0.10.1 与 win7 Gio v0.6.0 的 `widget.Clickable` 都提供 `Click`/`Clicked`,`layout.List.Position` 都暴露 First/Offset/Count,可用同一确定性场景矩阵而不跨 workspace import。 - 2026-07-17:本地 `go doc` 确认 modern Gio v0.10.1 与 win7 Gio v0.6.0 的 `widget.Clickable` 都提供 `Click`/`Clicked`,`layout.List.Position` 都暴露 First/Offset/Count,可用同一确定性场景矩阵而不跨 workspace import。
- 2026-07-17:在 `agent/codex/T-608` 分支领取任务,基线为 `0705948d749722af5814ee91267cb44db6c8164c`;保持单 Agent 串行执行。
- 2026-07-17:基线 `./init.ps1` 通过,包含治理/上下文/边界/依赖版本检查、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的测试和 Windows amd64 构建。
- 2026-07-17:modern/win7 各新增 `adapter_contract_test.go`,四组同名场景只在 edition 与真实窗口尺寸上不同。测试用 `Clickable.Click`、Editor 状态与下一帧 `Layout` 驱动 `drainInput`,并用 `input.Router` 语义树确认“软件目录尚未加载”“没有匹配的软件”和“显示全部软件”恢复入口确实被布局。
- 2026-07-17:契约覆盖三个视图按钮、分类/恢复/关闭事件,重排后保留 row control 仍按 AppID 打开正确详情,关闭详情保留 query/category/view 与 list First/Offset,500 项同时以 `lastRendered` 和 `layout.List.Position.Count` 证明 viewport 子集,并验证 app/category controls 复用及删除释放。两端首轮均通过,没有发现需要修改生产 `shell.go` 的漂移。
- 2026-07-17:矩阵一致性 `git diff --no-index` 仅显示预期的 Modern/Legacy edition 与 1080×720/1024×680 viewport 差异;modern Go 1.25.0 与 win7 Go 1.20.14 的 `go test -count=10 ./ui/gio` 分别通过,对应 `go vet ./ui/gio` 通过。
- 2026-07-17:完整 `./scripts/verify_phase0.ps1` 通过,包含治理/上下文/边界/版本校验、Go 1.20.14 core vet/test、modern Go 1.25 与 win7 Go 1.20.14 的 UI/平台测试及 Windows amd64 构建;路由、架构、审核追踪和当前状态已同步。