diff --git a/tests/StdUnit.Tags.Tests/S7Tags/S7BitTagCbntorTests.cs b/tests/StdUnit.Tags.Tests/S7Tags/S7BitTagCbntorTests.cs
new file mode 100644
index 0000000..ea4f6d3
--- /dev/null
+++ b/tests/StdUnit.Tags.Tests/S7Tags/S7BitTagCbntorTests.cs
@@ -0,0 +1,119 @@
+using StdUnit.Tags.S7;
+using System;
+using Xunit;
+
+namespace StdUnit.Tags.Tests.S7Tags;
+
+///
+/// 的取值/赋值:按 定位缓存字节的第 NthBit 位,
+/// 只改动目标位、保留同字节其它位,并打上脏标记。
+///
+public class S7BitTagCbntorTests
+{
+ private static (S7TagCbnt Cbnt, S7BitTagCbntor Tag) Create(int cacheSize = 4, int tagOffset = 1, int cacheOffset = 1, byte nthBit = 3)
+ {
+ var cbnt = new S7TagCbnt(new TagCbntDescriptor { Name = "cbnt1", StartAddress = "DB200.100" });
+ cbnt.ResizeCache(cacheSize);
+
+ var descriptor = new TagDescriptor
+ {
+ TagName = "bit-tag",
+ TagKind = BuiltinTagKinds.BIT,
+ RawAddress = $"DB200.{100 + tagOffset}.{nthBit}",
+ TagSize = 1,
+ };
+ var tag = new S7BitTagCbntor(descriptor, cbnt, tagOffset, cacheOffset, nthBit);
+ return (cbnt, tag);
+ }
+
+ [Fact]
+ public void Value_SetTrue_ShouldSetTargetBitAndMarkDirty()
+ {
+ var (cbnt, tag) = Create(cacheOffset: 1, nthBit: 3);
+
+ tag.Value = true;
+
+ Assert.Equal(0b0000_1000, cbnt.Cache.Span[1]);
+ Assert.True(tag.IsDirty);
+ Assert.True(cbnt.IsDirty);
+ Assert.NotEqual(default, tag.Timestamp);
+ Assert.True((DateTime.UtcNow - tag.Timestamp).Duration() < TimeSpan.FromMinutes(1));
+ }
+
+ [Fact]
+ public void Value_SetFalse_ShouldClearTargetBitOnly()
+ {
+ var (cbnt, tag) = Create(cacheOffset: 1, nthBit: 3);
+ cbnt.Cache.Span[1] = 0xFF;
+
+ tag.Value = false;
+
+ Assert.Equal(0b1111_0111, cbnt.Cache.Span[1]);
+ Assert.True(tag.IsDirty);
+ Assert.True(cbnt.IsDirty);
+ }
+
+ [Fact]
+ public void Value_SetTrue_ShouldNotTouchOtherBytes()
+ {
+ var (cbnt, tag) = Create(cacheOffset: 1, nthBit: 0);
+ cbnt.Cache.Span[0] = 0xAA;
+ cbnt.Cache.Span[2] = 0xBB;
+
+ tag.Value = true;
+
+ Assert.Equal(0xAA, cbnt.Cache.Span[0]);
+ Assert.Equal(0b0000_0001, cbnt.Cache.Span[1]);
+ Assert.Equal(0xBB, cbnt.Cache.Span[2]);
+ }
+
+ ///
+ /// 跨字节位(地址写成 DB200.101.9 之类)在工厂里会归一化成 NthBit=1、CacheOffset=TagOffset+1,
+ /// 赋值必须按 CacheOffset 写缓存,而不是按 TagOffset。
+ ///
+ [Fact]
+ public void Value_SetTrue_ShouldWriteAtCacheOffsetNotTagOffset()
+ {
+ var (cbnt, tag) = Create(tagOffset: 1, cacheOffset: 2, nthBit: 1);
+
+ tag.Value = true;
+
+ Assert.Equal(0, cbnt.Cache.Span[1]);
+ Assert.Equal(0b0000_0010, cbnt.Cache.Span[2]);
+ }
+
+ [Fact]
+ public void Value_Get_ShouldReadTargetBit()
+ {
+ var (cbnt, tag) = Create(cacheOffset: 1, nthBit: 3);
+ cbnt.Cache.Span[1] = 0b0000_1000;
+
+ Assert.True((bool)tag.Value!);
+
+ cbnt.Cache.Span[1] = 0b0000_0000;
+ Assert.False((bool)tag.Value!);
+ }
+
+ [Fact]
+ public void Value_SetNonBool_ShouldThrowArgumentException()
+ {
+ var (cbnt, tag) = Create(cacheOffset: 1, nthBit: 3);
+
+ var ex = Assert.Throws(() => tag.Value = 1);
+
+ Assert.Equal("value", ex.ParamName);
+ Assert.False(tag.IsDirty);
+ Assert.False(cbnt.IsDirty);
+ Assert.Equal(0, cbnt.Cache.Span[1]);
+ }
+
+ [Fact]
+ public void Value_SetNull_ShouldThrowArgumentException()
+ {
+ var (_, tag) = Create(cacheOffset: 1, nthBit: 3);
+
+ var ex = Assert.Throws(() => tag.Value = null);
+
+ Assert.Equal("value", ex.ParamName);
+ }
+}
diff --git a/tests/StdUnit.Tags.Tests/S7Tags/S7TagFactoryTests.cs b/tests/StdUnit.Tags.Tests/S7Tags/S7TagFactoryTests.cs
new file mode 100644
index 0000000..77ec1f9
--- /dev/null
+++ b/tests/StdUnit.Tags.Tests/S7Tags/S7TagFactoryTests.cs
@@ -0,0 +1,160 @@
+using StdUnit.Tags.S7;
+using System;
+using System.Xml.Linq;
+using Xunit;
+
+namespace StdUnit.Tags.Tests.S7Tags;
+
+///
+/// :测点种类分派、TagSize 归一化、位地址跨字节归一化、STR 的 maxlen 处理。
+///
+public class S7TagFactoryTests
+{
+ private static S7TagFactory CreateFactory(string startAddress = "DB200.100")
+ {
+ var builder = new S7TagCbntBuilder();
+ builder.WithCbntDescriptor(new TagCbntDescriptor { Name = "cbnt1", StartAddress = startAddress });
+ return builder.MakeS7TagFactory();
+ }
+
+ private static TagDescriptor CreateDescriptor(string kind, string rawAddress, int tagSize = 0, string tagName = "tag")
+ => new TagDescriptor
+ {
+ TagName = tagName,
+ TagKind = kind,
+ RawAddress = rawAddress,
+ TagSize = tagSize,
+ };
+
+ [Theory]
+ [InlineData(BuiltinTagKinds.BIT, typeof(S7BitTagCbntor), 1, "DB200.104.1")]
+ [InlineData(BuiltinTagKinds.BYTE, typeof(S7ByteTagCbntor), 1, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT16, typeof(S7Int16TagCbntor), 2, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT16, typeof(S7UInt16TagCbntor), 2, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT32, typeof(S7Int32TagCbntor), 4, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT32, typeof(S7UInt32TagCbntor), 4, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT64, typeof(S7Int64TagCbntor), 8, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT64, typeof(S7UInt64TagCbntor), 8, "DB200.104")]
+ [InlineData(BuiltinTagKinds.FLOAT, typeof(S7FloatTagCbntor), 4, "DB200.104")]
+ public void CreateTag_WhenTagSizeIsZero_ShouldCreateExpectedCbntorAndNormalizeTagSize(
+ string kind, Type expectedType, int expectedTagSize, string rawAddress)
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(kind, rawAddress);
+
+ var tag = factory.CreateTag(descriptor);
+
+ Assert.Equal(expectedType, tag.GetType());
+ Assert.Equal(expectedTagSize, descriptor.TagSize);
+ Assert.Equal(expectedTagSize, tag.TagSize());
+ Assert.Equal(4, tag.TagOffset);
+ }
+
+ [Theory]
+ [InlineData(BuiltinTagKinds.BIT, "DB200.104.1")]
+ [InlineData(BuiltinTagKinds.BYTE, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT16, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT16, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT32, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT32, "DB200.104")]
+ [InlineData(BuiltinTagKinds.INT64, "DB200.104")]
+ [InlineData(BuiltinTagKinds.UINT64, "DB200.104")]
+ [InlineData(BuiltinTagKinds.FLOAT, "DB200.104")]
+ public void CreateTag_WhenTagSizeIsExplicit_ShouldKeepIt(string kind, string rawAddress)
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(kind, rawAddress, tagSize: 16);
+
+ factory.CreateTag(descriptor);
+
+ Assert.Equal(16, descriptor.TagSize);
+ }
+
+ [Theory]
+ [InlineData("DB200.104.0", 4, 4, 0)]
+ [InlineData("DB200.104.7", 4, 4, 7)]
+ [InlineData("DB200.104.8", 4, 5, 0)]
+ [InlineData("DB200.104.9", 4, 5, 1)]
+ [InlineData("DB200.104.15", 4, 5, 7)]
+ public void CreateTag_Bit_ShouldNormalizeNthBitAcrossBytes(
+ string rawAddress, int expectedTagOffset, int expectedCacheOffset, byte expectedNthBit)
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(BuiltinTagKinds.BIT, rawAddress);
+
+ var tag = Assert.IsType(factory.CreateTag(descriptor));
+
+ Assert.Equal(expectedTagOffset, tag.TagOffset);
+ Assert.Equal(expectedCacheOffset, tag.CacheOffset);
+ Assert.Equal(expectedNthBit, tag.NthBit);
+ Assert.Equal(1, descriptor.TagSize);
+ }
+
+ [Fact]
+ public void CreateTag_ShouldCalculateOffsetFromCbntStartAddress()
+ {
+ var factory = CreateFactory("DB200.100");
+ var descriptor = CreateDescriptor(BuiltinTagKinds.INT32, "DB200.132");
+
+ var tag = factory.CreateTag(descriptor);
+
+ Assert.Equal(32, tag.TagOffset);
+ }
+
+ [Fact]
+ public void CreateTag_Str_ShouldCalculateSizeFromMaxlen()
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(BuiltinTagKinds.STR, "DB200.110", tagName: "str-tag");
+ descriptor.Extras["maxlen"] = new XAttribute("maxlen", "10");
+
+ var tag = Assert.IsType(factory.CreateTag(descriptor));
+
+ Assert.Equal(10, tag.Maxlen);
+ Assert.Equal(12, descriptor.TagSize);
+ Assert.Equal(10, tag.TagOffset);
+ }
+
+ [Fact]
+ public void CreateTag_Str_WhenMaxlenIsMissing_ShouldThrowTagsProjectXmlException()
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(BuiltinTagKinds.STR, "DB200.110", tagName: "str-tag");
+
+ var ex = Assert.Throws(() => factory.CreateTag(descriptor));
+
+ Assert.Contains("maxlen", ex.Message);
+ Assert.Contains("Tag(str-tag)", ex.Location);
+ }
+
+ [Theory]
+ [InlineData("abc")]
+ [InlineData("0")]
+ public void CreateTag_Str_WhenMaxlenIsInvalid_ShouldThrowTagsProjectXmlException(string maxlen)
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(BuiltinTagKinds.STR, "DB200.110", tagName: "str-tag");
+ descriptor.Extras["maxlen"] = new XAttribute("maxlen", maxlen);
+
+ var ex = Assert.Throws(() => factory.CreateTag(descriptor));
+
+ Assert.Contains("maxlen", ex.Message);
+ Assert.Contains("Tag(str-tag)", ex.Location);
+ }
+
+ [Theory]
+ [InlineData(BuiltinTagKinds.DOUBLE)]
+ [InlineData(BuiltinTagKinds.DI)]
+ [InlineData(BuiltinTagKinds.DO)]
+ [InlineData(BuiltinTagKinds.Unknown)]
+ public void CreateTag_WhenTagKindIsUnsupported_ShouldThrowTagsProjectConfigurationException(string kind)
+ {
+ var factory = CreateFactory();
+ var descriptor = CreateDescriptor(kind, "DB200.104", tagName: "bad-tag");
+
+ var ex = Assert.Throws(() => factory.CreateTag(descriptor));
+
+ Assert.Contains(kind, ex.Message);
+ Assert.Contains("Tag(bad-tag)", ex.Location);
+ }
+}