Repository: tableauio/tableau
Pull request #135 (2024-09-02 08:12:12)

feat: support document union & xml incell struct
1. support union for documents (xml & yaml)
2. support xml incell struct & incell predefined struct

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/tableauio/tableau/pull/135?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) Report
Attention: Patch coverage is `42.40000%` with `72 lines` in your changes missing coverage. Please review.
> Project coverage is 55.28%. Comparing base [(`5278280`)](https://app.codecov.io/gh/tableauio/tableau/commit/52782806f11a5a34150dd85a00eb10d74d0e5de2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) to head [(`1d02a3d`)](https://app.codecov.io/gh/tableauio/tableau/commit/1d02a3d5b12094c989a477730b21c01979ef25a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).

| [Files with missing lines](https://app.codecov.io/gh/tableauio/tableau/pull/135?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) | Patch % | Lines |
|---|---|---|
| [internal/confgen/document\_parser.go](https://app.codecov.io/gh/tableauio/tableau/pull/135?src=pr&el=tree&filepath=internal%2Fconfgen%2Fdocument_parser.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-aW50ZXJuYWwvY29uZmdlbi9kb2N1bWVudF9wYXJzZXIuZ28=) | 42.55% | [38 Missing and 16 partials :warning: ](https://app.codecov.io/gh/tableauio/tableau/pull/135?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) |
| [internal/importer/xml.go](https://app.codecov.io/gh/tableauio/tableau/pull/135?src=pr&el=tree&filepath=internal%2Fimporter%2Fxml.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-aW50ZXJuYWwvaW1wb3J0ZXIveG1sLmdv) | 41.93% | [18 Missing :warning: ](https://app.codecov.io/gh/tableauio/tableau/pull/135?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #135      +/-   ##
==========================================
- Coverage   55.29%   55.28%   -0.01%     
==========================================
  Files          74       74              
  Lines        9241     9349     +108     
==========================================
+ Hits         5110     5169      +59     
- Misses       3574     3609      +35     
- Partials      557      571      +14     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/tableauio/tableau/pull/135?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).


Patch:
diff --git a/internal/confgen/document_parser.go b/internal/confgen/document_parser.go
index e4e23b4..6592c2e 100644
--- a/internal/confgen/document_parser.go
+++ b/internal/confgen/document_parser.go
@@ -1,6 +1,7 @@
 package confgen
 
 import (
+	"strconv"
 	"strings"
 
 	"github.com/tableauio/tableau/internal/confgen/prop"
@@ -314,7 +315,13 @@ func (sp *documentParser) parseListField(field *Field, msg protoreflect.Message,
 		for _, elemNode := range node.Children {
 			elemPresent := false
 			newListValue := list.NewElement()
-			if field.fd.Kind() == protoreflect.MessageKind {
+			if xproto.IsUnionField(field.fd) {
+				// cross-cell union list
+				elemPresent, err = sp.parseUnionMessage(field, newListValue.Message(), elemNode)
+				if err != nil {
+					return false, xerrors.WithMessageKV(err, elemNode.DebugKV()...)
+				}
+			} else if field.fd.Kind() == protoreflect.MessageKind {
 				// cross-cell struct list
 				elemPresent, err = sp.parseMessage(newListValue.Message(), elemNode)
 				if err != nil {
@@ -343,7 +350,38 @@ func (sp *documentParser) parseListField(field *Field, msg protoreflect.Message,
 }
 
 func (sp *documentParser) parseUnionField(field *Field, msg protoreflect.Message, node *book.Node) (present bool, err error) {
-	return false, xerrors.Errorf("union type not supported yet")
+	var structValue protoreflect.Value
+	if msg.Has(field.fd) {
+		// Get it if this field is populated. It will be overwritten if present.
+		structValue = msg.Mutable(field.fd)
+	} else {
+		structValue = msg.NewField(field.fd)
+	}
+
+	if field.opts.Span == tableaupb.Span_SPAN_INNER_CELL {
+		if present, err = sp.parser.parseIncellUnion(structValue, node.Value, field.opts.GetProp().GetForm()); err != nil {
+			return false, xerrors.WithMessageKV(err, node.DebugNameKV()...)
+		}
+		if present {
+			msg.Set(field.fd, structValue)
+		}
+		return present, nil
+	}
+
+	if node.Kind == book.ListNode {
+		if len(node.Children) != 1 {
+			return false, xerrors.ErrorKV("list node of union must have and only have one child", node.DebugKV()...)
+		}
+		node = node.Children[0]
+	}
+	present, err = sp.parseUnionMessage(field, structValue.Message(), node)
+	if err != nil {
+		return false, xerrors.WithMessageKV(err, node.DebugKV()...)
+	}
+	if present {
+		msg.Set(field.fd, structValue)
+	}
+	return present, nil
 }
 
 func (sp *documentParser) parseStructField(field *Field, msg protoreflect.Message, node *book.Node) (present bool, err error) {
@@ -412,3 +450,101 @@ func (sp *documentParser) parseScalarField(field *Field, msg protoreflect.Messag
 	msg.Set(field.fd, newValue)
 	return true, nil
 }
+
+func (sp *documentParser) parseUnionMessage(field *Field, msg protoreflect.Message, node *book.Node) (present bool, err error) {
+	unionDesc := xproto.ExtractUnionDescriptor(field.fd.Message())
+	if unionDesc == nil {
+		return false, xerrors.Errorf("illegal definition of union: %s", field.fd.Message().FullName())
+	}
+
+	// parse union type
+	typeNodeName := unionDesc.TypeName()
+	typeNode := node.FindChild(typeNodeName)
+	if typeNode == nil && xproto.GetFieldDefaultValue(unionDesc.Type) != "" {
+		// if this field has a default value, use virtual node
+		typeNode = &book.Node{
+			Name:  node.Name,
+			Value: node.Value,
+		}
+	}
+	if typeNode == nil {
+		if sp.parser.IsFieldOptional(field) {
+			// field not found and is optional, just return nil.
+			return false, nil
+		}
+		kvs := node.DebugNameKV()
+		kvs = append(kvs,
+			xerrors.KeyPBFieldType, xproto.GetFieldTypeName(unionDesc.Type),
+			xerrors.KeyPBFieldName, unionDesc.Type.FullName(),
+			xerrors.KeyPBFieldOpts, field.opts,
+		)
+		return false, xerrors.WrapKV(xerrors.E2014(field.opts.Name), kvs...)
+	}
+
+	var typeVal protoreflect.Value
+	typeVal, present, err = sp.parser.parseFieldValue(unionDesc.Type, typeNode.Value, nil)
+	if err != nil {
+		return false, xerrors.WithMessageKV(err, typeNode.DebugNameKV()...)
+	}
+	msg.Set(unionDesc.Type, typeVal)
+
+	// parse value
+	fieldNumber := int32(typeVal.Enum())
+	if fieldNumber == 0 {
+		// default enum value 0, no need to parse.
+		return false, nil
+	}
+	valueFD := unionDesc.GetValueByNumber(fieldNumber)
+	if valueFD == nil {
+		typeValue := unionDesc.Type.Enum().Values().ByNumber(protoreflect.EnumNumber(fieldNumber)).Name()
+		return false, xerrors.WithMessageKV(xerrors.E2010(typeValue, fieldNumber), node.DebugKV()...)
+	}
+	fieldValue := msg.NewField(valueFD)
+	if valueFD.Kind() == protoreflect.MessageKind {
+		// MUST be message type.
+		md := valueFD.Message()
+		msg := fieldValue.Message()
+		for i := 0; i < md.Fields().Len(); i++ {
+			fd := md.Fields().Get(i)
+			valNodeName := unionDesc.ValueFieldName() + strconv.Itoa(int(fd.Number()))
+			err := func() error {
+				subField := parseFieldDescriptor(fd, sp.parser.opts.Sep, sp.parser.opts.Subsep)
+				defer subField.release()
+				valNode := node.FindChild(valNodeName)
+				if valNode == nil && xproto.GetFieldDefaultValue(fd) != "" {
+					// if this field has a default value, use virtual node
+					valNode = &book.Node{
+						Name:  node.Name,
+						Value: node.Value,
+					}
+				}
+				if valNode == nil {
+					if sp.parser.IsFieldOptional(subField) {
+						// field not found and is optional, just return nil.
+						return nil
+					}
+					kvs := node.DebugNameKV()
+					kvs = append(kvs,
+						xerrors.KeyPBFieldType, xproto.GetFieldTypeName(fd),
+						xerrors.KeyPBFieldName, fd.FullName(),
+						xerrors.KeyPBFieldOpts, subField.opts,
+					)
+					return xerrors.WrapKV(xerrors.E2014(subField.opts.Name), kvs...)
+				}
+				err = sp.parser.parseUnionMessageField(subField, msg, valNode.Value)
+				if err != nil {
+					return xerrors.WithMessageKV(err, valNode.DebugNameKV()...)
+				}
+				return nil
+			}()
+			if err != nil {
+				return false, err
+			}
+		}
+	} else {
+		// scalar: not supported yet.
+		return false, xerrors.Errorf("union value (oneof) as scalar type not supported: %s", valueFD.FullName())
+	}
+	msg.Set(valueFD, fieldValue)
+	return present, nil
+}
diff --git a/internal/importer/xml.go b/internal/importer/xml.go
index 6475728..59613b5 100644
--- a/internal/importer/xml.go
+++ b/internal/importer/xml.go
@@ -511,25 +511,45 @@ func parseXMLAttribute(bnode *book.Node, attrName, attrValue string, isFirstAttr
 			}
 		}
 	} else if desc := types.MatchStruct(attrValue); desc != nil {
-		curBNode = &book.Node{
-			Kind: book.MapNode,
-			Name: book.KeywordStruct,
-			Children: []*book.Node{
-				{
-					Name:  attrName,
-					Value: desc.ColumnType,
-				},
-			},
-		}
-		if isFirstAttr {
+		switch {
+		case desc.ColumnType == "", // predefined incell struct, e.g.: {.Item}
+			strings.Contains(desc.StructType, " "): // incell struct, e.g.: {int32 ID, string Name}Item
 			bnode.Children = append(bnode.Children, &book.Node{
-				Name:  book.KeywordType,
-				Value: fmt.Sprintf("{%s}", bnode.Name),
-			}, curBNode)
-			return curBNode, nil
-		} else {
-			bnode.Children = append(bnode.Children, curBNode)
+				Kind: book.MapNode,
+				Name: attrName,
+				Children: []*book.Node{
+					{
+						Name:  book.KeywordType,
+						Value: attrValue,
+					},
+					{
+						Name:  book.KeywordIncell,
+						Value: "true",
+					},
+				},
+			})
 			return bnode, nil
+		default:
+			curBNode = &book.Node{
+				Kind: book.MapNode,
+				Name: book.KeywordStruct,
+				Children: []*book.Node{
+					{
+						Name:  attrName,
+						Value: desc.ColumnType,
+					},
+				},
+			}
+			if isFirstAttr {
+				bnode.Children = append(bnode.Children, &book.Node{
+					Name:  book.KeywordType,
+					Value: fmt.Sprintf("{%s}", desc.StructType),
+				}, curBNode)
+				return curBNode, nil
+			} else {
+				bnode.Children = append(bnode.Children, curBNode)
+				return bnode, nil
+			}
 		}
 	} else if isFirstAttr {
 		// generate struct when first encounter scalar attribute


Test patch:
diff --git a/internal/importer/testdata/Test.xml b/internal/importer/testdata/Test.xml
index 8ea6b56..84ded0c 100644
--- a/internal/importer/testdata/Test.xml
+++ b/internal/importer/testdata/Test.xml
@@ -14,5 +14,12 @@
     <EnumIncell>[]enum<.FruitType></EnumIncell>
     <ScalarList>[int32]</ScalarList>
     <Predefined @type="[.Item]"/>
+    <Struct ID="uint32" Name="string" />
+    <StructWithTypeName ID="{CustomStruct}uint32" Name="string" />
+    <IncellStruct>{uint32 ID, int32 Num}IncellStruct</IncellStruct>
+    <PredefinedIncellStruct>{.Vector3}</PredefinedIncellStruct>
+    <PredefinedUnion @type="{union.Target}" />
+    <PredefinedIncellUnion>{union.Target}|{form:FORM_TEXT}</PredefinedIncellUnion>
+    <Union @type="[union.Target]" />
 </Item>
 -->
diff --git a/test/functest/conf/XMLStructConf.json b/test/functest/conf/XMLStructConf.json
index f70b1ae..55810bf 100644
--- a/test/functest/conf/XMLStructConf.json
+++ b/test/functest/conf/XMLStructConf.json
@@ -3,6 +3,10 @@
         "id": 1,
         "name": "Apple"
     },
+    "item2": {
+        "id": 2,
+        "name": "Banana"
+    },
     "fruitShopData": {
         "fruitMap": {
             "apple": {
@@ -21,5 +25,14 @@
             }
         }
     },
-    "empty": null
+    "empty": null,
+    "award": {
+        "id": 3,
+        "num": 30
+    },
+    "vector": {
+        "x": 2.7182817,
+        "y": 3.1415925,
+        "z": -1
+    }
 }
\ No newline at end of file
diff --git a/test/functest/conf/XMLUnionConf.json b/test/functest/conf/XMLUnionConf.json
new file mode 100644
index 0000000..ed9dc40
--- /dev/null
+++ b/test/functest/conf/XMLUnionConf.json
@@ -0,0 +1,66 @@
+{
+    "predefinedUnion": {
+        "type": "TYPE_PVP",
+        "pvp": {
+            "type": 1,
+            "damage": "10",
+            "types": [
+                "FRUIT_TYPE_APPLE",
+                "FRUIT_TYPE_ORANGE",
+                "FRUIT_TYPE_BANANA"
+            ]
+        }
+    },
+    "predefinedIncellUnion": {
+        "type": "TYPE_PVE",
+        "pve": {
+            "mission": {
+                "id": 1,
+                "level": 100,
+                "damage": "999"
+            },
+            "heros": [
+                1,
+                2,
+                3
+            ],
+            "dungeons": {
+                "1": "10",
+                "2": "20",
+                "3": "30"
+            }
+        }
+    },
+    "unionList": [
+        {
+            "type": "TYPE_STORY",
+            "story": {
+                "cost": {
+                    "id": 1001,
+                    "num": 10
+                },
+                "fruits": {
+                    "1": "FRUIT_TYPE_APPLE",
+                    "2": "FRUIT_TYPE_ORANGE"
+                },
+                "flavors": {
+                    "1": {
+                        "key": "FRUIT_FLAVOR_FRAGRANT",
+                        "value": 1
+                    },
+                    "2": {
+                        "key": "FRUIT_FLAVOR_SOUR",
+                        "value": 2
+                    }
+                }
+            }
+        },
+        {
+            "type": "TYPE_SKILL",
+            "skill": {
+                "id": 1,
+                "damage": "2"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/test/functest/conf/YamlUnionConf.json b/test/functest/conf/YamlUnionConf.json
new file mode 100644
index 0000000..ed9dc40
--- /dev/null
+++ b/test/functest/conf/YamlUnionConf.json
@@ -0,0 +1,66 @@
+{
+    "predefinedUnion": {
+        "type": "TYPE_PVP",
+        "pvp": {
+            "type": 1,
+            "damage": "10",
+            "types": [
+                "FRUIT_TYPE_APPLE",
+                "FRUIT_TYPE_ORANGE",
+                "FRUIT_TYPE_BANANA"
+            ]
+        }
+    },
+    "predefinedIncellUnion": {
+        "type": "TYPE_PVE",
+        "pve": {
+            "mission": {
+                "id": 1,
+                "level": 100,
+                "damage": "999"
+            },
+            "heros": [
+                1,
+                2,
+                3
+            ],
+            "dungeons": {
+                "1": "10",
+                "2": "20",
+                "3": "30"
+            }
+        }
+    },
+    "unionList": [
+        {
+            "type": "TYPE_STORY",
+            "story": {
+                "cost": {
+                    "id": 1001,
+                    "num": 10
+                },
+                "fruits": {
+                    "1": "FRUIT_TYPE_APPLE",
+                    "2": "FRUIT_TYPE_ORANGE"
+                },
+                "flavors": {
+                    "1": {
+                        "key": "FRUIT_FLAVOR_FRAGRANT",
+                        "value": 1
+                    },
+                    "2": {
+                        "key": "FRUIT_FLAVOR_SOUR",
+                        "value": 2
+                    }
+                }
+            }
+        },
+        {
+            "type": "TYPE_SKILL",
+            "skill": {
+                "id": 1,
+                "damage": "2"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/test/functest/proto/common/union.proto b/test/functest/proto/common/union.proto
index c9a8c92..c903487 100644
--- a/test/functest/proto/common/union.proto
+++ b/test/functest/proto/common/union.proto
@@ -11,7 +11,10 @@ import "common/common.proto";
 message Target {
   option (tableau.union) = true;
 
-  Type type = 9999 [(tableau.field) = { name: "Type" }];
+  Type type = 9999 [(tableau.field) = {
+    name: "Type"
+    prop: { default: "TYPE_PVP" }
+  }];
   oneof value {
     option (tableau.oneof) = {
       field: "Field"
diff --git a/test/functest/proto/xml__struct.proto b/test/functest/proto/xml__struct.proto
index 6085f89..58519b1 100644
--- a/test/functest/proto/xml__struct.proto
+++ b/test/functest/proto/xml__struct.proto
@@ -19,10 +19,21 @@ message XMLStructConf {
     uint32 id = 1 [(tableau.field) = {name:"ID"}];
     string name = 2 [(tableau.field) = {name:"Name"}];
   }
-  protoconf.FruitInfo fruit_shop_data = 2 [(tableau.field) = {name:"FruitShopData"}];
-  Empty empty = 3 [(tableau.field) = {name:"Empty"}];
+  OtherItem item_2 = 2 [(tableau.field) = {name:"Item2"}];
+  message OtherItem {
+    uint32 id = 1 [(tableau.field) = {name:"ID"}];
+    string name = 2 [(tableau.field) = {name:"Name"}];
+  }
+  protoconf.FruitInfo fruit_shop_data = 3 [(tableau.field) = {name:"FruitShopData"}];
+  Empty empty = 4 [(tableau.field) = {name:"Empty"}];
   message Empty {
   }
+  Award award = 5 [(tableau.field) = {name:"Award" span:SPAN_INNER_CELL}];
+  message Award {
+    uint32 id = 1 [(tableau.field) = {name:"ID"}];
+    int32 num = 2 [(tableau.field) = {name:"Num"}];
+  }
+  protoconf.Vector3 vector = 6 [(tableau.field) = {name:"Vector" span:SPAN_INNER_CELL}];
 }
 
 message XMLStructConf2 {
diff --git a/test/functest/proto/xml__union.proto b/test/functest/proto/xml__union.proto
new file mode 100644
index 0000000..61e808d
--- /dev/null
+++ b/test/functest/proto/xml__union.proto
@@ -0,0 +1,20 @@
+// Code generated by tableau (protogen v0.6.0). DO NOT EDIT.
+// clang-format off
+
+syntax = "proto3";
+
+package protoconf;
+
+import "common/union.proto";
+import "tableau/protobuf/tableau.proto";
+
+option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
+option (tableau.workbook) = {name:"xml/Union.xml"};
+
+message XMLUnionConf {
+  option (tableau.worksheet) = {name:"XMLUnionConf"};
+
+  union.Target predefined_union = 1 [(tableau.field) = {name:"PredefinedUnion"}];
+  union.Target predefined_incell_union = 2 [(tableau.field) = {name:"PredefinedIncellUnion" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}];
+  repeated union.Target union_list = 3 [(tableau.field) = {name:"Union"}];
+}
diff --git a/test/functest/proto/yaml__union.proto b/test/functest/proto/yaml__union.proto
new file mode 100644
index 0000000..9bce4a0
--- /dev/null
+++ b/test/functest/proto/yaml__union.proto
@@ -0,0 +1,20 @@
+// Code generated by tableau (protogen v0.6.0). DO NOT EDIT.
+// clang-format off
+
+syntax = "proto3";
+
+package protoconf;
+
+import "common/union.proto";
+import "tableau/protobuf/tableau.proto";
+
+option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
+option (tableau.workbook) = {name:"yaml/Union.yaml"};
+
+message YamlUnionConf {
+  option (tableau.worksheet) = {name:"YamlUnionConf"};
+
+  union.Target predefined_union = 1 [(tableau.field) = {name:"PredefinedUnion"}];
+  union.Target predefined_incell_union = 2 [(tableau.field) = {name:"PredefinedIncellUnion" span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}];
+  repeated union.Target union_list = 3 [(tableau.field) = {name:"UnionList"}];
+}
diff --git a/test/functest/testdata/xml/Struct.xml b/test/functest/testdata/xml/Struct.xml
index 67daf03..23b4984 100644
--- a/test/functest/testdata/xml/Struct.xml
+++ b/test/functest/testdata/xml/Struct.xml
@@ -7,9 +7,12 @@
 
 <XMLStructConf>
     <Item ID="uint32" Name="string" />
+    <Item2 ID="{OtherItem}uint32" Name="string" />
     <FruitShopData @type="{.FruitInfo}" />
     <Empty>
     </Empty>
+    <Award>{uint32 ID, int32 Num}Award</Award>
+    <Vector>{.Vector3}</Vector>
 </XMLStructConf>
 
 <XMLStructConf2>
@@ -19,6 +22,7 @@
 
 <XMLStructConf>
     <Item ID="1" Name="Apple" VerboseAttr="A kind of fruit."/>
+    <Item2 ID="2" Name="Banana" />
     <VerboseElement ID="10" Num="100" />
     <FruitShopData>
         <Fruit Name="apple" Flavor="Sour,Sweet"/>
@@ -26,6 +30,8 @@
     </FruitShopData>
     <Empty>
     </Empty>
+    <Award>3, 30</Award>
+    <Vector>2.7182818, 3.1415926, -1</Vector>
 </XMLStructConf>
 
 <XMLStructConf2>
diff --git a/test/functest/testdata/xml/Union.xml b/test/functest/testdata/xml/Union.xml
new file mode 100644
index 0000000..f23ec2f
--- /dev/null
+++ b/test/functest/testdata/xml/Union.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+<@TABLEAU>
+    <Item Sheet="XMLUnionConf" />
+</@TABLEAU>
+
+<XMLUnionConf>
+    <PredefinedUnion @type="{union.Target}" />
+    <PredefinedIncellUnion>{union.Target}|{form:FORM_TEXT}</PredefinedIncellUnion>
+    <Union @type="[union.Target]" />
+</XMLUnionConf>
+-->
+
+<XMLUnionConf>
+    <PredefinedUnion Field1="1" Field2="10" Field3="Apple,Orange,Banana"/>
+    <PredefinedIncellUnion>type:TYPE_PVE pve:{mission:{id:1 level:100 damage:999} heros:1 heros:2 heros:3 dungeons:{key:1 value:10} dungeons:{key:2 value:20} dungeons:{key:3 value:30}}</PredefinedIncellUnion>
+    <Union Type="Story" Field1="1001,10" Field2="1:Apple,2:Orange" Field3="Fragrant:1,Sour:2"/>
+    <Union Type="Skill" Field1="1" Field2="2"/>
+</XMLUnionConf>
\ No newline at end of file
diff --git a/test/functest/testdata/yaml/Union.yaml b/test/functest/testdata/yaml/Union.yaml
new file mode 100644
index 0000000..5776dc2
--- /dev/null
+++ b/test/functest/testdata/yaml/Union.yaml
@@ -0,0 +1,25 @@
+# define metasheet: generate all sheets
+"@sheet": "@TABLEAU"
+---
+# define schema
+"@sheet": "@YamlUnionConf"
+PredefinedUnion: "{union.Target}"
+PredefinedIncellUnion:
+  "@type": "{union.Target}|{form:FORM_TEXT}"
+  "@incell": true
+UnionList: "[union.Target]"
+---
+"@sheet": YamlUnionConf
+PredefinedUnion:
+  Field1: 1
+  Field2: 10
+  Field3: Apple,Orange,Banana
+PredefinedIncellUnion: "type:TYPE_PVE pve:{mission:{id:1 level:100 damage:999} heros:1 heros:2 heros:3 dungeons:{key:1 value:10} dungeons:{key:2 value:20} dungeons:{key:3 value:30}}"
+UnionList:
+  - Type: Story
+    Field1: 1001,10
+    Field2: 1:Apple,2:Orange
+    Field3: Fragrant:1,Sour:2
+  - Type: Skill
+    Field1: 1
+    Field2: 2