diff --git a/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs
new file mode 100644
index 0000000..912fe56
--- /dev/null
+++ b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs
@@ -0,0 +1,100 @@
+using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
+
+namespace DnsmasqWebUI.Tests.Helpers;
+
+///
+/// Sample config line per option for consistent parser coverage. When adding new options to the app,
+/// add an entry here so the option-coverage test continues to exercise every option.
+///
+public static class OptionCoverageData
+{
+ /// Option key and a minimal valid config line (without newline).
+ public static IReadOnlyList<(string OptionKey, string ConfigLine)> GetParserCoverageEntries()
+ {
+ return new (string, string)[]
+ {
+ // LastWins
+ (DnsmasqConfKeys.Port, "port=53"),
+ (DnsmasqConfKeys.CacheSize, "cache-size=500"),
+ (DnsmasqConfKeys.LocalTtl, "local-ttl=300"),
+ (DnsmasqConfKeys.MxTarget, "mx-target=mail.example.com"),
+ (DnsmasqConfKeys.DhcpLeasefile, "dhcp-leasefile=/var/lib/dnsmasq.leases"),
+ (DnsmasqConfKeys.DhcpLeaseMax, "dhcp-lease-max=1000"),
+ (DnsmasqConfKeys.Hostsdir, "hostsdir=/etc/dnsmasq.d"),
+ (DnsmasqConfKeys.PidFile, "pid-file=/run/dnsmasq.pid"),
+ (DnsmasqConfKeys.User, "user=nobody"),
+ (DnsmasqConfKeys.TftpRoot, "tftp-root=/var/lib/tftpboot"),
+ // Flag
+ (DnsmasqConfKeys.NoHosts, "no-hosts"),
+ (DnsmasqConfKeys.ExpandHosts, "expand-hosts"),
+ (DnsmasqConfKeys.BogusPriv, "bogus-priv"),
+ (DnsmasqConfKeys.NoResolv, "no-resolv"),
+ (DnsmasqConfKeys.DomainNeeded, "domain-needed"),
+ (DnsmasqConfKeys.StrictOrder, "strict-order"),
+ (DnsmasqConfKeys.AllServers, "all-servers"),
+ (DnsmasqConfKeys.BindInterfaces, "bind-interfaces"),
+ (DnsmasqConfKeys.DhcpAuthoritative, "dhcp-authoritative"),
+ (DnsmasqConfKeys.EnableTftp, "enable-tftp"),
+ (DnsmasqConfKeys.Dnssec, "dnssec"),
+ (DnsmasqConfKeys.Conntrack, "conntrack"),
+ (DnsmasqConfKeys.Do0x20Encode, "do-0x20-encode"),
+ (DnsmasqConfKeys.No0x20Encode, "no-0x20-encode"),
+ // Multi - resolver / DNS
+ (DnsmasqConfKeys.Server, "server=1.1.1.1"),
+ (DnsmasqConfKeys.Local, "local=/lan/"),
+ (DnsmasqConfKeys.RevServer, "rev-server=192.168.1.0/24,10.0.0.1"),
+ (DnsmasqConfKeys.Address, "address=/example.com/127.0.0.1"),
+ (DnsmasqConfKeys.Domain, "domain=home.lan,192.168.1.0/24"),
+ (DnsmasqConfKeys.Cname, "cname=www.example.com,example.com"),
+ (DnsmasqConfKeys.MxHost, "mx-host=example.com,mail.example.com,10"),
+ (DnsmasqConfKeys.Srv, "srv-host=_http._tcp.example.com,host.example.com,80"),
+ (DnsmasqConfKeys.PtrRecord, "ptr-record=1.168.192.in-addr.arpa,router.lan"),
+ (DnsmasqConfKeys.TxtRecord, "txt-record=example.com,text"),
+ (DnsmasqConfKeys.NaptrRecord, "naptr-record=example.com,0,0,a,s,r"),
+ (DnsmasqConfKeys.DnsRr, "dns-rr=example.com,16,01:02"),
+ (DnsmasqConfKeys.HostRecord, "host-record=host.example.com,1.2.3.4"),
+ (DnsmasqConfKeys.DynamicHost, "dynamic-host=example.com"),
+ (DnsmasqConfKeys.CaaRecord, "caa-record=example.com,0,issue,letsencrypt.org"),
+ (DnsmasqConfKeys.RebindDomainOk, "rebind-domain-ok=example.com"),
+ (DnsmasqConfKeys.BogusNxdomain, "bogus-nxdomain=192.168.1.1"),
+ (DnsmasqConfKeys.IgnoreAddress, "ignore-address=192.168.1.1"),
+ (DnsmasqConfKeys.Alias, "alias=0.0.0.0,example.com"),
+ (DnsmasqConfKeys.FilterRr, "filter-rr=0.0.0.0,A"),
+ (DnsmasqConfKeys.CacheRr, "cache-rr=0.0.0.0,A"),
+ (DnsmasqConfKeys.Ipset, "ipset=/example.com/set1"),
+ (DnsmasqConfKeys.Nftset, "nftset=/example.com/table/set"),
+ (DnsmasqConfKeys.ConnmarkAllowlist, "connmark-allowlist=0xff,example.com"),
+ // Multi - auth / synth
+ (DnsmasqConfKeys.AuthServer, "auth-server=zone.example.com,eth0"),
+ (DnsmasqConfKeys.SynthDomain, "synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100"),
+ (DnsmasqConfKeys.AuthZone, "auth-zone=example.com"),
+ (DnsmasqConfKeys.TrustAnchor, "trust-anchor=.,20326,8,2,E4F1769B8A0E3142E125223716F878F0A3B2C3D4"),
+ // Multi - DHCP
+ (DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"),
+ (DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"),
+ (DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"),
+ (DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"),
+ (DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"),
+ (DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"),
+ (DnsmasqConfKeys.DhcpIgnoreNames, "dhcp-ignore-names=tag:guest"),
+ (DnsmasqConfKeys.DhcpBoot, "dhcp-boot=pxelinux.0"),
+ (DnsmasqConfKeys.Leasequery, "leasequery=10.0.0.0/24"),
+ (DnsmasqConfKeys.RaParam, "ra-param=eth0,high"),
+ (DnsmasqConfKeys.Slaac, "slaac=eth0"),
+ (DnsmasqConfKeys.PxeService, "pxe-service=x86PC,Install Linux"),
+ (DnsmasqConfKeys.DhcpRelay, "dhcp-relay=192.168.1.1,eth0"),
+ (DnsmasqConfKeys.DhcpCircuitid, "dhcp-circuitid=eth0,010203"),
+ (DnsmasqConfKeys.DhcpRemoteid, "dhcp-remoteid=010203"),
+ (DnsmasqConfKeys.DhcpSubscrid, "dhcp-subscrid=010203"),
+ (DnsmasqConfKeys.TagIf, "tag-if=set:tag,eth0"),
+ (DnsmasqConfKeys.SharedNetwork, "shared-network=name,192.168.1.0/24"),
+ (DnsmasqConfKeys.DhcpOptionPxe, "dhcp-option-pxe=set:known,1"),
+ (DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass=set:pxe,PXEClient"),
+ (DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass=set:efi,EFI"),
+ (DnsmasqConfKeys.AddnHosts, "addn-hosts=/etc/hosts.extra"),
+ (DnsmasqConfKeys.Interface, "interface=eth0"),
+ (DnsmasqConfKeys.ListenAddress, "listen-address=127.0.0.1"),
+ (DnsmasqConfKeys.ResolvFile, "resolv-file=/etc/resolv.dnsmasq"),
+ };
+ }
+}
diff --git a/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs
new file mode 100644
index 0000000..55860e6
--- /dev/null
+++ b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs
@@ -0,0 +1,51 @@
+using System.Text.Json;
+
+namespace DnsmasqWebUI.Tests.Helpers;
+
+/// Expected parser/effective state for a real-world corpus case. Counts and booleans only.
+public sealed class CorpusExpected
+{
+ public int? ServerCountMin { get; set; }
+ public int? LocalCountMin { get; set; }
+ public int? AddressCountMin { get; set; }
+ public int? CacheSize { get; set; }
+ public int? AddnHostsCountMin { get; set; }
+ public int? DomainCountMin { get; set; }
+ public bool? NoHosts { get; set; }
+ public bool? NoResolv { get; set; }
+ public bool? BogusPriv { get; set; }
+ public string? Do0x20State { get; set; }
+ public int? Port { get; set; }
+ public bool? ExpandHosts { get; set; }
+ public int? CnameCountMin { get; set; }
+ public int? TxtRecordCountMin { get; set; }
+ public int? SrvCountMin { get; set; }
+ public int? PtrRecordCountMin { get; set; }
+ public int? MxHostCountMin { get; set; }
+ public int? AuthZoneCountMin { get; set; }
+ public int? SynthDomainCountMin { get; set; }
+ public int? AuthServerCountMin { get; set; }
+}
+
+/// Single real-world corpus case from dnsmasq-real-cases.json.
+public sealed class CorpusCase
+{
+ public string File { get; set; } = "";
+ public CorpusExpected Expected { get; set; } = new();
+ public List SemanticInvalidOptions { get; set; } = [];
+}
+
+/// Loads real-world corpus index and resolves paths via TestDataHelper.
+public static class RealWorldCasesHelper
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
+
+ public static IReadOnlyList LoadCases()
+ {
+ var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json");
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? [];
+ }
+
+ public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File);
+}
diff --git a/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs
new file mode 100644
index 0000000..91d18a3
--- /dev/null
+++ b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs
@@ -0,0 +1,50 @@
+using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
+using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
+
+namespace DnsmasqWebUI.Tests.Helpers;
+
+/// Ensures corpus index and files stay in sync; fails fast when testdata drifts.
+public class RealWorldCorpusIntegrityTests
+{
+ [Fact]
+ public void EveryCorpusCase_PointsToExistingFile()
+ {
+ var cases = RealWorldCasesHelper.LoadCases();
+ Assert.NotEmpty(cases);
+ foreach (var c in cases)
+ {
+ var path = RealWorldCasesHelper.Resolve(c);
+ Assert.True(File.Exists(path), $"Corpus file missing: {c.File}");
+ }
+ }
+
+ [Fact]
+ public void EveryBadCase_DeclaresSemanticInvalidOptions()
+ {
+ var cases = RealWorldCasesHelper.LoadCases();
+ var badFiles = new[] { "bad/", "real-world/bad/" };
+ foreach (var c in cases)
+ {
+ var isBad = badFiles.Any(p => c.File.Contains(p, StringComparison.Ordinal));
+ if (isBad)
+ Assert.NotEmpty(c.SemanticInvalidOptions);
+ }
+ }
+
+ [Fact]
+ public void SemanticInvalidOptions_ArePresentInParsedValues()
+ {
+ foreach (var c in RealWorldCasesHelper.LoadCases())
+ {
+ if (c.SemanticInvalidOptions.Count == 0)
+ continue;
+ var mainPath = RealWorldCasesHelper.Resolve(c);
+ var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
+ foreach (var option in c.SemanticInvalidOptions)
+ {
+ var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
+ Assert.NotEmpty(values);
+ }
+ }
+ }
+}
diff --git a/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs b/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs
deleted file mode 100644
index 78316c0..0000000
--- a/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
-
-namespace DnsmasqWebUI.Tests.Models;
-
-///
-/// Tests for and .
-///
-public class ConfigValueSourceTests
-{
- [Fact]
- public void GetReadOnlyTooltip_WhenManaged_ReturnsNull()
- {
- var source = new ConfigValueSource("/data/zz-managed.conf", "zz-managed.conf", IsManaged: true, LineNumber: 5);
- Assert.Null(source.GetReadOnlyTooltip());
- }
-
- [Fact]
- public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
- {
- var fileName = "02.conf";
- var fullPath = $"/etc/dnsmasq.d/{fileName}";
- var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
- var tooltip = source.GetReadOnlyTooltip();
- Assert.NotNull(tooltip);
- Assert.Contains(fileName, tooltip);
- Assert.Contains("readonly", tooltip);
- Assert.Contains(fullPath, tooltip);
- Assert.DoesNotContain("line", tooltip);
- }
-
- [Fact]
- public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
- {
- var fileName = "02.conf";
- var fullPath = $"/etc/dnsmasq.d/{fileName}";
- int? lineNumber = 3;
- var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
- var tooltip = source.GetReadOnlyTooltip();
- Assert.NotNull(tooltip);
- Assert.Contains(fileName, tooltip);
- Assert.Contains($"line {lineNumber}", tooltip);
- Assert.Contains("readonly", tooltip);
- Assert.Contains(fullPath, tooltip);
- }
-
- [Fact]
- public void IsReadOnly_WhenManaged_IsFalse()
- {
- var source = new ConfigValueSource("/data/zz.conf", "zz.conf", IsManaged: true);
- Assert.False(source.IsReadOnly);
- }
-
- [Fact]
- public void IsReadOnly_WhenNotManaged_IsTrue()
- {
- var source = new ConfigValueSource("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false);
- Assert.True(source.IsReadOnly);
- }
-}
diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs
index 36feaf3..5dda710 100644
--- a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs
+++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs
@@ -80,32 +80,6 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
}
- [Fact]
- public void OfficialExample_FileContainsExpectedOptionNames()
- {
- var path = GetOfficialExamplePath();
- var content = File.ReadAllText(path);
- // Sanity: official example documents the options we support (as commented lines).
- Assert.Contains("read-ethers", content);
- Assert.Contains("dhcp-option-force", content);
- Assert.Contains("dhcp-rapid-commit", content);
- Assert.Contains("dhcp-script", content);
- Assert.Contains("tftp-no-fail", content);
- Assert.Contains("tftp-no-blocksize", content);
- Assert.Contains("mx-target", content);
- Assert.Contains("localmx", content);
- Assert.Contains("selfmx", content);
- Assert.Contains("enable-ra", content);
- Assert.Contains("log-dhcp", content);
- Assert.Contains("ipset=", content);
- Assert.Contains("nftset=", content);
- Assert.Contains("dhcp-mac", content);
- Assert.Contains("dhcp-name-match", content);
- Assert.Contains("dhcp-ignore-names", content);
- // SRV records use option name srv-host (not "srv")
- Assert.Contains("srv-host", content);
- }
-
[Fact]
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
@@ -124,4 +98,23 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.False(noHosts);
}
+
+ /// Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only.
+ [Fact]
+ public void OfficialLikeActive_CommentedLinesIgnored_ActiveLinesParsed()
+ {
+ var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-official-like-active.conf");
+ Assert.True(File.Exists(mainPath));
+ var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
+
+ var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
+ Assert.NotNull(portVal);
+ Assert.True(int.TryParse(portVal, out var port) && port == 5353);
+
+ var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
+ Assert.Single(servers);
+ Assert.Equal("1.1.1.1", servers[0]);
+
+ Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
+ }
}
diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs
new file mode 100644
index 0000000..237b14f
--- /dev/null
+++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs
@@ -0,0 +1,73 @@
+using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
+using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
+using DnsmasqWebUI.Tests.Helpers;
+
+namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
+
+///
+/// Ensures every option in the coverage list is parsed consistently: one config line per option,
+/// dispatch by parser behavior (Flag / LastWins / Multi), assert we get a value back.
+/// Add new options to when adding to the app.
+///
+public class DnsmasqConfIncludeParserOptionCoverageTests
+{
+ public static IEnumerable