@@ -1224,6 +1224,386 @@ describe('translatePage', () => {
12241224 } ) ;
12251225} ) ;
12261226
1227+ // ────────────────────────────────────────────────────────────────────────────
1228+ // #12961 — the descent into a container's declared `properties.children`
1229+ //
1230+ // Ruled 2026-08-29 (maintainer, verbatim 「同意」, option A): `translatePage`
1231+ // descends into DECLARED `properties.children` arrays, so copy authored for a
1232+ // nested component id is resolved; the region-only boundary that stood at
1233+ // `i18n-resolver.ts:944-946` is deliberately reversed. Collision rule fixed at
1234+ // the ruling: region-level id WINS; among nested matches, document-order first.
1235+ // Recursion depth-guarded and cycle-safe — `children` is authored data. The
1236+ // published face does not move: `pages.<name>.components.<id>` has ALWAYS
1237+ // parsed copy for a nested id, so this widens the resolver to the face.
1238+ //
1239+ // Shape is the measured hotCRM one: `sales_home_page`'s `key_metrics` card
1240+ // holds the four `object-metric` KPI blocks in its `properties.children`, and
1241+ // their labels stayed English while the header and card titles translated.
1242+ // ────────────────────────────────────────────────────────────────────────────
1243+
1244+ describe ( 'translatePage — nested `properties.children` descent (#12961)' , ( ) => {
1245+ /**
1246+ * Fixtures are typed through these open shapes instead of being left to
1247+ * literal inference. `translatePage<T extends PageLike>` returns `T`, so an
1248+ * inferred literal type carries only the keys the LITERAL spells — reading
1249+ * back a key the overlay ADDS, or a `children` array a sibling branch of the
1250+ * union does not declare, is then a type error rather than a test. Same
1251+ * widening the #6080 block does inline (`as Record<string, string>`), hoisted
1252+ * because this block builds a dozen fixtures. Written as type ALIASES so they
1253+ * keep the implicit index signature `PageLike` needs.
1254+ */
1255+ type FixtureComponent = { type ?: string ; id ?: string ; label ?: string ; properties : Record < string , any > } ;
1256+ type FixturePage = {
1257+ name : string ;
1258+ label ?: string ;
1259+ regions : Array < { name : string ; components : FixtureComponent [ ] } > ;
1260+ } ;
1261+
1262+ const kpiBundle : TranslationBundle = {
1263+ 'zh-CN' : {
1264+ pages : {
1265+ sales_home_page : {
1266+ label : '销售看板' ,
1267+ components : {
1268+ key_metrics : { title : '关键指标' } ,
1269+ kpi_revenue_won : { label : '已赢收入' } ,
1270+ kpi_deals_won : { label : '赢单数' } ,
1271+ kpi_pipeline_value : { label : '管道金额' } ,
1272+ kpi_open_leads : { label : '未处理线索' } ,
1273+ } ,
1274+ } ,
1275+ } ,
1276+ } ,
1277+ } ;
1278+
1279+ /** The hotCRM `sales_home_page` shape: KPI blocks nested in a card. */
1280+ const kpiPage = ( ) : FixturePage => ( {
1281+ name : 'sales_home_page' ,
1282+ label : 'Sales Home' ,
1283+ regions : [ {
1284+ name : 'main' ,
1285+ components : [
1286+ {
1287+ type : 'page:card' ,
1288+ id : 'key_metrics' ,
1289+ properties : {
1290+ title : 'Key Metrics' ,
1291+ children : [
1292+ { type : 'object-metric' , id : 'kpi_revenue_won' , properties : { label : 'Revenue (Won)' } } ,
1293+ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ,
1294+ { type : 'object-metric' , id : 'kpi_pipeline_value' , properties : { label : 'Pipeline Value' } } ,
1295+ { type : 'object-metric' , id : 'kpi_open_leads' , properties : { label : 'Open Leads' } } ,
1296+ ] ,
1297+ } ,
1298+ } ,
1299+ ] ,
1300+ } ] ,
1301+ } ) ;
1302+
1303+ const card = ( doc : any ) => doc . regions [ 0 ] . components [ 0 ] ;
1304+ const kid = ( doc : any , id : string ) =>
1305+ card ( doc ) . properties . children . find ( ( c : any ) => c ?. id === id ) ;
1306+
1307+ it ( 'resolves copy for every KPI block nested in the card (the measured residual)' , ( ) => {
1308+ const out = translatePage ( kpiPage ( ) , kpiBundle , { locale : 'zh-CN' } ) ;
1309+ expect ( kid ( out , 'kpi_revenue_won' ) . properties . label ) . toBe ( '已赢收入' ) ;
1310+ expect ( kid ( out , 'kpi_deals_won' ) . properties . label ) . toBe ( '赢单数' ) ;
1311+ expect ( kid ( out , 'kpi_pipeline_value' ) . properties . label ) . toBe ( '管道金额' ) ;
1312+ expect ( kid ( out , 'kpi_open_leads' ) . properties . label ) . toBe ( '未处理线索' ) ;
1313+ } ) ;
1314+
1315+ it ( 'still translates the containing card and the page itself' , ( ) => {
1316+ const out = translatePage ( kpiPage ( ) , kpiBundle , { locale : 'zh-CN' } ) ;
1317+ expect ( out . label ) . toBe ( '销售看板' ) ;
1318+ expect ( card ( out ) . properties . title ) . toBe ( '关键指标' ) ;
1319+ } ) ;
1320+
1321+ it ( 'descends recursively, not one level' , ( ) => {
1322+ const doc : FixturePage = {
1323+ name : 'sales_home_page' ,
1324+ regions : [ {
1325+ name : 'main' ,
1326+ components : [ {
1327+ type : 'page:section' ,
1328+ properties : {
1329+ children : [ {
1330+ type : 'page:card' ,
1331+ id : 'key_metrics' ,
1332+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ] } ,
1333+ } ] ,
1334+ } ,
1335+ } ] ,
1336+ } ] ,
1337+ } ;
1338+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1339+ const section = out . regions [ 0 ] . components [ 0 ] ;
1340+ expect ( section . properties . children [ 0 ] . properties . title ) . toBe ( '关键指标' ) ;
1341+ expect ( section . properties . children [ 0 ] . properties . children [ 0 ] . properties . label ) . toBe ( '赢单数' ) ;
1342+ } ) ;
1343+
1344+ it ( 'does not mutate the input page' , ( ) => {
1345+ const doc = kpiPage ( ) ;
1346+ const snapshot = JSON . parse ( JSON . stringify ( doc ) ) ;
1347+ translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1348+ expect ( doc ) . toEqual ( snapshot ) ;
1349+ } ) ;
1350+
1351+ it ( 'leaves non-component `children` entries — bare id strings — as they are' , ( ) => {
1352+ // `children` is declared `z.array(z.unknown())`; a bare component-id string
1353+ // is a legal entry and must ride through untouched, not be spread into an
1354+ // object.
1355+ const doc = {
1356+ name : 'sales_home_page' ,
1357+ regions : [ {
1358+ name : 'main' ,
1359+ components : [ {
1360+ type : 'page:card' ,
1361+ id : 'key_metrics' ,
1362+ properties : { children : [ 'kpi_revenue_won' , null , 42 , { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ] } ,
1363+ } ] ,
1364+ } ] ,
1365+ } ;
1366+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1367+ expect ( card ( out ) . properties . children . slice ( 0 , 3 ) ) . toEqual ( [ 'kpi_revenue_won' , null , 42 ] ) ;
1368+ expect ( card ( out ) . properties . children [ 3 ] . properties . label ) . toBe ( '赢单数' ) ;
1369+ } ) ;
1370+
1371+ // ── The boundary the ruling drew: `children`, and only `children` ────────
1372+ describe ( 'the descended slot' , ( ) => {
1373+ const nestedUnder = ( slotProps : Record < string , unknown > ) : FixturePage => ( {
1374+ name : 'sales_home_page' ,
1375+ regions : [ {
1376+ name : 'main' ,
1377+ components : [ { type : 'page:card' , id : 'key_metrics' , properties : slotProps } ] ,
1378+ } ] ,
1379+ } ) ;
1380+
1381+ it ( 'does not descend into `body` — the back-compat spelling is not authorable (#5775)' , ( ) => {
1382+ // The renderers read `schema.children || schema.body` for STORED
1383+ // documents, but `body` is deliberately NOT a declared authoring key.
1384+ // Descending into it would resurrect a second composition spelling.
1385+ const out = translatePage (
1386+ nestedUnder ( { body : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ] } ) ,
1387+ kpiBundle ,
1388+ { locale : 'zh-CN' } ,
1389+ ) ;
1390+ expect ( ( card ( out ) . properties . body as any [ ] ) [ 0 ] . properties . label ) . toBe ( 'Deals Won' ) ;
1391+ } ) ;
1392+
1393+ it ( 'does not descend into `items[].children` — outside the ruled `properties.children` face' , ( ) => {
1394+ // `page:tabs` / `page:accordion` nest their children one level deeper,
1395+ // under `properties.items[].children`. The ruling names
1396+ // `properties.children`; widening further is its own contract call, so
1397+ // this records where the line is rather than silently crossing it.
1398+ const out = translatePage (
1399+ nestedUnder ( { items : [ { label : 'Details' , children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ] } ] } ) ,
1400+ kpiBundle ,
1401+ { locale : 'zh-CN' } ,
1402+ ) ;
1403+ expect ( ( card ( out ) . properties . items as any [ ] ) [ 0 ] . children [ 0 ] . properties . label ) . toBe ( 'Deals Won' ) ;
1404+ } ) ;
1405+
1406+ it ( 'keeps the page-name header route region-level' , ( ) => {
1407+ // `pages.<name>.{title,subtitle}` addresses THE page header. A
1408+ // `page:header` nested inside a container is not it — nested components
1409+ // are reached by the id route only.
1410+ const out = translatePage (
1411+ nestedUnder ( { children : [ { type : 'page:header' , properties : { title : 'Sales Home' } } ] } ) ,
1412+ kpiBundle ,
1413+ { locale : 'zh-CN' } ,
1414+ ) ;
1415+ expect ( ( card ( out ) . properties . children as any [ ] ) [ 0 ] . properties . title ) . toBe ( 'Sales Home' ) ;
1416+ } ) ;
1417+ } ) ;
1418+
1419+ // ── The ruled collision rule, both directions ───────────────────────────
1420+ describe ( 'id collisions across nesting levels' , ( ) => {
1421+ it ( 'gives the entry to the REGION-LEVEL component when a nested id repeats it' , ( ) => {
1422+ const doc : FixturePage = {
1423+ name : 'sales_home_page' ,
1424+ regions : [ {
1425+ name : 'main' ,
1426+ components : [
1427+ {
1428+ type : 'page:card' ,
1429+ id : 'shell' ,
1430+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Nested Deals Won' } } ] } ,
1431+ } ,
1432+ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Region Deals Won' } } ,
1433+ ] ,
1434+ } ] ,
1435+ } ;
1436+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1437+ const [ shell , regionLevel ] = out . regions [ 0 ] . components ;
1438+ expect ( regionLevel . properties . label ) . toBe ( '赢单数' ) ;
1439+ // …and the nested namesake keeps its literal — one entry, one winner.
1440+ expect ( shell . properties . children [ 0 ] . properties . label ) . toBe ( 'Nested Deals Won' ) ;
1441+ } ) ;
1442+
1443+ it ( 'wins region-level even when the region-level namesake comes LAST in a later region' , ( ) => {
1444+ // The rule is level-priority, not document order: a nested component
1445+ // that appears first in the document still loses to a region-level id.
1446+ const doc : FixturePage = {
1447+ name : 'sales_home_page' ,
1448+ regions : [
1449+ {
1450+ name : 'main' ,
1451+ components : [ {
1452+ type : 'page:card' ,
1453+ id : 'shell' ,
1454+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Nested Deals Won' } } ] } ,
1455+ } ] ,
1456+ } ,
1457+ { name : 'aside' , components : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Region Deals Won' } } ] } ,
1458+ ] ,
1459+ } ;
1460+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1461+ expect ( out . regions [ 1 ] . components [ 0 ] . properties . label ) . toBe ( '赢单数' ) ;
1462+ expect ( out . regions [ 0 ] . components [ 0 ] . properties . children [ 0 ] . properties . label ) . toBe ( 'Nested Deals Won' ) ;
1463+ } ) ;
1464+
1465+ it ( 'gives it to the DOCUMENT-ORDER FIRST nested match when no region-level id claims it' , ( ) => {
1466+ const doc : FixturePage = {
1467+ name : 'sales_home_page' ,
1468+ regions : [ {
1469+ name : 'main' ,
1470+ components : [
1471+ {
1472+ type : 'page:card' ,
1473+ id : 'first_shell' ,
1474+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'First Deals Won' } } ] } ,
1475+ } ,
1476+ {
1477+ type : 'page:card' ,
1478+ id : 'second_shell' ,
1479+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Second Deals Won' } } ] } ,
1480+ } ,
1481+ ] ,
1482+ } ] ,
1483+ } ;
1484+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1485+ const [ first , second ] = out . regions [ 0 ] . components ;
1486+ expect ( first . properties . children [ 0 ] . properties . label ) . toBe ( '赢单数' ) ;
1487+ expect ( second . properties . children [ 0 ] . properties . label ) . toBe ( 'Second Deals Won' ) ;
1488+ } ) ;
1489+
1490+ it ( 'reads document order depth-first — a deeper earlier match beats a shallower later one' , ( ) => {
1491+ const doc : FixturePage = {
1492+ name : 'sales_home_page' ,
1493+ regions : [ {
1494+ name : 'main' ,
1495+ components : [
1496+ {
1497+ type : 'page:card' ,
1498+ id : 'first_shell' ,
1499+ properties : {
1500+ children : [ {
1501+ type : 'page:section' ,
1502+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deep First' } } ] } ,
1503+ } ] ,
1504+ } ,
1505+ } ,
1506+ {
1507+ type : 'page:card' ,
1508+ id : 'second_shell' ,
1509+ properties : { children : [ { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Shallow Second' } } ] } ,
1510+ } ,
1511+ ] ,
1512+ } ] ,
1513+ } ;
1514+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1515+ const [ first , second ] = out . regions [ 0 ] . components ;
1516+ expect ( first . properties . children [ 0 ] . properties . children [ 0 ] . properties . label ) . toBe ( '赢单数' ) ;
1517+ expect ( second . properties . children [ 0 ] . properties . label ) . toBe ( 'Shallow Second' ) ;
1518+ } ) ;
1519+ } ) ;
1520+
1521+ // ── Depth guard / cycle safety — `children` is authored data ─────────────
1522+ describe ( 'depth guard and cycle safety' , ( ) => {
1523+ /**
1524+ * The resolver descends at most this many levels below region level.
1525+ * Module-private in `i18n-resolver.ts` (`MAX_NESTED_COMPONENT_DEPTH`) so
1526+ * the guard adds no public API; this literal is the PIN — raise the cap
1527+ * there and this test reds, which is the point.
1528+ */
1529+ const CAP = 32 ;
1530+
1531+ /**
1532+ * A leaf carrying `leafId` wrapped in `depth` containers — so the leaf
1533+ * sits exactly `depth` levels BELOW region level (`depth: 0` would make
1534+ * the leaf itself the region-level component).
1535+ */
1536+ const chain = ( depth : number , leafId : string ) : FixturePage => {
1537+ let node : any = { type : 'object-metric' , id : leafId , properties : { label : 'Deals Won' } } ;
1538+ for ( let i = 0 ; i < depth ; i ++ ) {
1539+ node = { type : 'page:section' , properties : { children : [ node ] } } ;
1540+ }
1541+ return {
1542+ name : 'sales_home_page' ,
1543+ regions : [ { name : 'main' , components : [ node ] } ] ,
1544+ } ;
1545+ } ;
1546+
1547+ const leafOf = ( doc : any ) => {
1548+ let node = doc . regions [ 0 ] . components [ 0 ] ;
1549+ while ( node ?. properties ?. children ) node = node . properties . children [ 0 ] ;
1550+ return node ;
1551+ } ;
1552+
1553+ it ( `translates a component nested exactly ${ CAP } levels below region level` , ( ) => {
1554+ const out = translatePage ( chain ( CAP , 'kpi_deals_won' ) , kpiBundle , { locale : 'zh-CN' } ) ;
1555+ expect ( leafOf ( out ) . properties . label ) . toBe ( '赢单数' ) ;
1556+ } ) ;
1557+
1558+ it ( `stops at the cap — a component ${ CAP + 1 } levels down keeps its literal` , ( ) => {
1559+ const out = translatePage ( chain ( CAP + 1 , 'kpi_deals_won' ) , kpiBundle , { locale : 'zh-CN' } ) ;
1560+ expect ( leafOf ( out ) . properties . label ) . toBe ( 'Deals Won' ) ;
1561+ } ) ;
1562+
1563+ it ( 'returns rather than recursing forever on an absurdly deep tree' , ( ) => {
1564+ // Deep enough that an unguarded walk is a stack-overflow risk; the guard
1565+ // makes the call finite whatever the document does.
1566+ const out = translatePage ( chain ( 200_000 , 'kpi_deals_won' ) , kpiBundle , { locale : 'zh-CN' } ) ;
1567+ expect ( out . label ) . toBe ( '销售看板' ) ;
1568+ } ) ;
1569+
1570+ it ( 'survives a component whose `children` contains itself' , ( ) => {
1571+ const selfRef : any = { type : 'page:card' , id : 'key_metrics' , properties : { title : 'Key Metrics' , children : [ ] as unknown [ ] } } ;
1572+ selfRef . properties . children . push ( selfRef ) ;
1573+ const doc = { name : 'sales_home_page' , regions : [ { name : 'main' , components : [ selfRef ] } ] } ;
1574+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1575+ expect ( out . regions [ 0 ] . components [ 0 ] . properties . title ) . toBe ( '关键指标' ) ;
1576+ } ) ;
1577+
1578+ it ( 'survives a two-node cycle' , ( ) => {
1579+ const a : any = { type : 'page:card' , id : 'key_metrics' , properties : { title : 'Key Metrics' , children : [ ] as unknown [ ] } } ;
1580+ const b : any = { type : 'page:section' , properties : { children : [ a ] } } ;
1581+ a . properties . children . push ( b ) ;
1582+ const doc = { name : 'sales_home_page' , regions : [ { name : 'main' , components : [ a ] } ] } ;
1583+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1584+ expect ( out . regions [ 0 ] . components [ 0 ] . properties . title ) . toBe ( '关键指标' ) ;
1585+ } ) ;
1586+
1587+ it ( 'translates a subtree that is REFERENCED twice without treating it as a cycle' , ( ) => {
1588+ // Shared references are not cycles: the second sighting is a legitimate
1589+ // second component, and the collision rule (not the cycle guard) decides
1590+ // that only the first gets the entry.
1591+ const shared : any = { type : 'object-metric' , id : 'kpi_deals_won' , properties : { label : 'Deals Won' } } ;
1592+ const doc = {
1593+ name : 'sales_home_page' ,
1594+ regions : [ {
1595+ name : 'main' ,
1596+ components : [ { type : 'page:card' , id : 'key_metrics' , properties : { children : [ shared , shared ] } } ] ,
1597+ } ] ,
1598+ } ;
1599+ const out = translatePage ( doc , kpiBundle , { locale : 'zh-CN' } ) ;
1600+ const children = card ( out ) . properties . children ;
1601+ expect ( children [ 0 ] . properties . label ) . toBe ( '赢单数' ) ;
1602+ expect ( children [ 1 ] . properties . label ) . toBe ( 'Deals Won' ) ;
1603+ } ) ;
1604+ } ) ;
1605+ } ) ;
1606+
12271607// ────────────────────────────────────────────────────────────────────────────
12281608// #5377 — filter-preset tab labels (`objects.<o>._tabs.<tab>.label`)
12291609// ────────────────────────────────────────────────────────────────────────────
0 commit comments