163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
package layerwire
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// isInbound reports whether a constructor is a client->server (Input*) type the
|
|
// server never emits, so it cannot appear in downgraded output.
|
|
func isInbound(cl *ctorLayout) bool {
|
|
return strings.HasPrefix(cl.result, "Input") || strings.HasPrefix(cl.name, "input")
|
|
}
|
|
|
|
// collectReachableTypes returns abstract/bare type names that can appear in
|
|
// downgraded output at a layer: those referenced by a *retained* field of a
|
|
// constructor that is itself emittable (not a function, not a 227-only type that
|
|
// is replaced wholesale, not an inbound Input* type).
|
|
func collectReachableTypes(lt *layerTables) map[string]bool {
|
|
refs := map[string]bool{}
|
|
var addField func(f *fieldLayout)
|
|
addField = func(f *fieldLayout) {
|
|
switch f.kind {
|
|
case kindObject, kindBareObject:
|
|
refs[f.typeName] = true
|
|
case kindVector, kindVectorBare:
|
|
addField(f.elem)
|
|
}
|
|
}
|
|
for crc, cl := range canonical.byCRC {
|
|
if cl.isFunc || isInbound(cl) || lt.newTypes[crc] {
|
|
continue
|
|
}
|
|
var keep map[string]bool
|
|
if r := lt.rules[crc]; r != nil && r.structural == "" {
|
|
keep = r.keep
|
|
}
|
|
for i := range cl.fields {
|
|
f := &cl.fields[i]
|
|
if f.isFlags {
|
|
continue
|
|
}
|
|
if keep != nil && !keep[f.name] {
|
|
continue // dropped by a mechanical rule
|
|
}
|
|
addField(f)
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// unemittedAllowlist is the curated set of reachable-but-unhandled 227-only
|
|
// constructors that telesrv does not actually emit (confirmed against the
|
|
// outbound-constructor scoping audit, 2026-06-25). They live behind features the
|
|
// server lacks (instant-view rich pages, AI compose, managed bots, web-browser
|
|
// settings, guest chat, star-gift rarity/craft, join-chat bot results). The gate
|
|
// fails if a NEW reachable type appears that is neither handled nor listed here,
|
|
// forcing a human to triage on every gotd bump / client upgrade.
|
|
var unemittedAllowlist = map[string]bool{
|
|
"aiComposeTone": true,
|
|
"aiComposeToneDefault": true,
|
|
"aiComposeToneExample": true,
|
|
"botInlineMessageRichMessage": true,
|
|
"channelAdminLogEventActionParticipantEditRank": true,
|
|
"joinChatBotResultApproved": true,
|
|
"joinChatBotResultDeclined": true,
|
|
"joinChatBotResultQueued": true,
|
|
"joinChatBotResultWebView": true,
|
|
"messages.chatInviteJoinResultWebView": true,
|
|
"messages.emojiGameDiceInfo": true,
|
|
"messages.emojiGameUnavailable": true,
|
|
"requestPeerTypeCreateBot": true,
|
|
"richMessage": true,
|
|
"sendMessageRichMessageDraftAction": true,
|
|
"starGiftAttributeRarity": true,
|
|
"starGiftAttributeRarityEpic": true,
|
|
"starGiftAttributeRarityLegendary": true,
|
|
"starGiftAttributeRarityRare": true,
|
|
"starGiftAttributeRarityUncommon": true,
|
|
"topPeerCategoryBotsGuestChat": true,
|
|
"updateAiComposeTones": true,
|
|
"updateBotGuestChatQuery": true,
|
|
"updateChatParticipantRank": true,
|
|
"updateEmojiGameInfo": true,
|
|
"updateJoinChatWebViewDecision": true,
|
|
"updateManagedBot": true,
|
|
"updateNewBotConnection": true,
|
|
"updateStarGiftCraftFail": true,
|
|
"updateWebBrowserException": true,
|
|
"updateWebBrowserSettings": true,
|
|
"webDomainException": true,
|
|
"webPageAttributeAiComposeTone": true,
|
|
// Structural changed-types telesrv does not emit (see design Appendix C and
|
|
// the scoping audit); their hand transforms are deferred to CI-todo.
|
|
"pageListOrderedItemText": true,
|
|
"pageListOrderedItemBlocks": true,
|
|
"starGiftAttributeModel": true,
|
|
"starGiftAttributeBackdrop": true,
|
|
"starGiftAttributePattern": true,
|
|
"urlAuthResultAccepted": true,
|
|
"inputMediaPoll": true, // inbound only
|
|
}
|
|
|
|
func newTypeHandled(crc uint32, result string) bool {
|
|
if newTypeFallbacks[crc] != nil {
|
|
return true
|
|
}
|
|
return newTypeFallbacksByType[result] != nil
|
|
}
|
|
|
|
// TestCoverageGate is the drift gate. For every supported layer, each 227-only
|
|
// or structural constructor that can appear in downgraded output must be either
|
|
// handled (fallback / structural transform) or explicitly allowlisted as not
|
|
// emitted. A bare failure means new wire shape slipped in unhandled.
|
|
func TestCoverageGate(t *testing.T) {
|
|
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
|
|
lt := tables[layer]
|
|
if lt == nil {
|
|
t.Fatalf("no tables for layer %d", layer)
|
|
}
|
|
reach := collectReachableTypes(lt)
|
|
|
|
// Structural rules that are reachable need a registered transform.
|
|
for crc, r := range lt.rules {
|
|
if r.structural == "" {
|
|
continue
|
|
}
|
|
cl := canonical.byCRC[crc]
|
|
reachable := cl != nil && reach[cl.result] && !isInbound(cl)
|
|
handled := structuralTransforms[r.structural] != nil
|
|
if reachable && !handled && !unemittedAllowlist[nameOf(crc)] {
|
|
t.Errorf("layer %d: reachable structural %s (%#08x) has no transform", layer, nameOf(crc), crc)
|
|
}
|
|
}
|
|
|
|
// New constructors reachable through a retained field need a fallback.
|
|
var gaps []string
|
|
for crc := range lt.newTypes {
|
|
cl := canonical.byCRC[crc]
|
|
if cl == nil || cl.isFunc || isInbound(cl) {
|
|
continue
|
|
}
|
|
if !reach[cl.result] {
|
|
continue
|
|
}
|
|
if newTypeHandled(crc, cl.result) || unemittedAllowlist[cl.name] {
|
|
continue
|
|
}
|
|
gaps = append(gaps, cl.name)
|
|
}
|
|
if len(gaps) > 0 {
|
|
sort.Strings(gaps)
|
|
t.Errorf("layer %d: %d reachable 227-only types lack a fallback or allowlist entry:\n %v", layer, len(gaps), gaps)
|
|
}
|
|
}
|
|
}
|
|
|
|
func nameOf(crc uint32) string {
|
|
if cl := canonical.byCRC[crc]; cl != nil {
|
|
return cl.name
|
|
}
|
|
return "?"
|
|
}
|