- Every network call returns a result whatever fails, not only the exceptions .NET usually throws: on Android the HTTPS client is the platform's own stack, and a network switch mid-request throws others. The outside test as a whole cannot throw; sharing the link falls back to copying it. A last guard (the UI thread, unobserved tasks, Android's raiser) writes errors.log in the app folder and keeps the app running; nothing is sent. - On mobile data the app no longer probes the operator's gateway as if it were a home router: the router card says there is none, a private address on the phone with another public one is the operator's address sharing (CGNAT) and is drawn as its shared exit, the public IPv4 is held by that exit, and the provider's notes are labelled as being about its home lines. These were "router not answering" and "can't tell" on an Iliad phone. - The route's routers appear as stops on the path's wires: the operator's internal ones before its exit, the public ones with their network number towards the internet. - The download speed is timed after the first quarter of the sample, once the connection has ramped up; the path no longer shows the address block's registry country. - Two new strings, drafted in every language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
439 lines
20 KiB
C#
439 lines
20 KiB
C#
using System.Globalization;
|
|
|
|
using MeWeb.Core.History;
|
|
using MeWeb.Core.Knowledge;
|
|
using MeWeb.Core.Net;
|
|
using MeWeb.Core.Router;
|
|
using MeWeb.Shared;
|
|
|
|
namespace MeWeb.Core.Checks;
|
|
|
|
public static class Verdicts
|
|
{
|
|
public static Finding Online(OnlineReport report, LocalSnapshot local)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Online };
|
|
switch (report.State)
|
|
{
|
|
case OnlineState.Online:
|
|
finding.Outcome = Outcome.Good;
|
|
finding.Summary = "OnlineGood";
|
|
break;
|
|
case OnlineState.Rewritten:
|
|
finding.Outcome = Outcome.Caution;
|
|
finding.Summary = "OnlineRewritten";
|
|
finding.With("FactRedirectedTo", report.Portal);
|
|
break;
|
|
case OnlineState.Captive:
|
|
finding.Outcome = Outcome.Caution;
|
|
finding.Summary = "OnlineCaptive";
|
|
finding.With("FactRedirectedTo", report.Portal);
|
|
break;
|
|
case OnlineState.NoNames when local.Active is not null:
|
|
finding.Outcome = Outcome.Bad;
|
|
finding.Summary = "OnlineNoNames";
|
|
break;
|
|
default:
|
|
finding.Outcome = Outcome.Bad;
|
|
finding.Summary = local.Active is null ? "OnlineNoNetwork" : "OnlineOffline";
|
|
break;
|
|
}
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Connection(LocalSnapshot local)
|
|
{
|
|
var active = local.Active;
|
|
if (active is null) return new Finding { Id = CheckId.Connection, Outcome = Outcome.Bad, Summary = "ConnectionNone" };
|
|
var finding = new Finding
|
|
{
|
|
Id = CheckId.Connection,
|
|
Outcome = Outcome.Info,
|
|
Summary = active.Kind switch
|
|
{
|
|
LinkKind.Wifi => "ConnectionWifi",
|
|
LinkKind.Cable => "ConnectionCable",
|
|
LinkKind.Mobile => "ConnectionMobile",
|
|
_ => "ConnectionOther",
|
|
},
|
|
};
|
|
finding.With("FactAdapter", active.Name);
|
|
if (active.SpeedMbps > 0) finding.WithKey("FactLinkSpeed", "ValueMbps", active.SpeedMbps.ToString(CultureInfo.InvariantCulture));
|
|
finding.With("FactLocalAddress", local.LocalV4);
|
|
var v6 = local.GlobalV6;
|
|
if (v6.Count > 0) finding.With("FactLocalIpv6", string.Join("\n", v6));
|
|
finding.With("FactGateway", local.GatewayV4 ?? active.Gateways.FirstOrDefault());
|
|
if (local.Dns.Count > 0) finding.With("FactDns", string.Join(", ", local.Dns.Take(3)));
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Addresses(string v4, string v6, LocalSnapshot local)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Addresses };
|
|
(finding.Outcome, finding.Summary) = (v4, v6) switch
|
|
{
|
|
(not null, not null) => (Outcome.Good, "AddressesBoth"),
|
|
(not null, null) => (Outcome.Caution, "AddressesV4Only"),
|
|
(null, not null) => (Outcome.Caution, "AddressesV6Only"),
|
|
_ => (Outcome.Unknown, "AddressesNone"),
|
|
};
|
|
if (v4 is not null)
|
|
{
|
|
finding.With("FactPublicV4", v4);
|
|
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true;
|
|
var shared = local.Active?.Kind == LinkKind.Mobile || Net.Addresses.Parse(local.LocalV4) is { } address && Net.Addresses.Classify(address) == AddressKind.Shared;
|
|
finding.WithKey("FactWhoHasIt", own ? "ValueOnThisDevice" : shared ? "ValueSharedProvider" : "ValueSharedAtHome");
|
|
}
|
|
if (v6 is not null)
|
|
{
|
|
finding.With("FactPublicV6", v6);
|
|
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
|
finding.WithKey("FactWhoHasIpv6", own ? "ValueOnThisDevice" : "ValueIpv6NotThisDevice");
|
|
}
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Provider(KnowledgeBase knowledge, AsnEntry v4, AsnEntry v6, bool anyAddress, bool mobile)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Provider };
|
|
if (!anyAddress)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "ProviderNoAddress";
|
|
return finding;
|
|
}
|
|
if (!knowledge.HasAsnTable)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "ProviderNoMap";
|
|
return finding;
|
|
}
|
|
var main = v4 ?? v6;
|
|
if (main is null)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "ProviderNotFound";
|
|
return finding;
|
|
}
|
|
var profile = knowledge.ProviderFor(main.Number);
|
|
finding.Outcome = Outcome.Info;
|
|
finding.Summary = profile is null ? "ProviderUnlisted" : mobile ? "ProviderListedMobile" : "ProviderListed";
|
|
finding.Args = [profile?.Name ?? Display(main)];
|
|
finding.With("FactNetwork", Describe(main));
|
|
if (v6 is not null && v4 is not null && v6.Number != v4.Number) finding.With("FactNetworkIpv6", Describe(v6));
|
|
if (profile is null) return finding;
|
|
AddFact(finding, "FactProviderIpv4", "ProviderIpv4", profile.Ipv4);
|
|
AddFact(finding, "FactProviderPublic", "ProviderPublic", profile.PublicAddress);
|
|
AddFact(finding, "FactProviderIpv6", "ProviderIpv6", profile.Ipv6);
|
|
AddFact(finding, "FactProviderPort25", "ProviderPort25", profile.Port25);
|
|
if (profile.Sheet is not null) finding.Facts.Add(new Fact { Label = "FactProviderSheet", Key = "ValueOpenSheet", Link = profile.Sheet });
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Router(RouterReport router, LocalSnapshot local)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Router };
|
|
if (router.Gateway is null)
|
|
{
|
|
finding.Outcome = Outcome.Info;
|
|
finding.Summary = local.Active?.Kind == LinkKind.Mobile ? "RouterMobile" : "RouterNone";
|
|
return finding;
|
|
}
|
|
(finding.Outcome, finding.Summary) = router switch
|
|
{
|
|
{ AnyMapping: true } => (Outcome.Good, "RouterOpens"),
|
|
{ AnyAnswer: true } => (Outcome.Caution, "RouterRefuses"),
|
|
_ => (Outcome.Caution, "RouterSilent"),
|
|
};
|
|
finding.With("FactRouter", Join(router.Make, router.Label));
|
|
finding.With("FactGateway", router.Gateway);
|
|
finding.WithKey("FactUpnp", AnswerKey(router.Upnp), router.Upnp == Answer.Refused ? router.UpnpError.ToString(CultureInfo.InvariantCulture) : router.UpnpVersion.ToString(CultureInfo.InvariantCulture));
|
|
finding.WithKey("FactNatPmp", AnswerKey(router.NatPmp), router.NatPmpResult.ToString(CultureInfo.InvariantCulture));
|
|
finding.WithKey("FactPcp", AnswerKey(router.Pcp), router.PcpResult.ToString(CultureInfo.InvariantCulture));
|
|
finding.With("FactRouterWan", router.WanAddress);
|
|
return finding;
|
|
}
|
|
|
|
public static (Finding Finding, NatKind Kind) Nat(LocalSnapshot local, RouterReport router, string v4, string v6, List<Hop> hops)
|
|
{
|
|
var kind = NatOf(local, router, v4);
|
|
var finding = new Finding { Id = CheckId.Nat };
|
|
(finding.Outcome, finding.Summary) = kind switch
|
|
{
|
|
NatKind.Direct => (Outcome.Good, "NatDirect"),
|
|
NatKind.HomeRouter => (Outcome.Good, "NatHomeRouter"),
|
|
NatKind.DoubleNat => (Outcome.Caution, "NatDouble"),
|
|
NatKind.Shared => (Outcome.Bad, "NatShared"),
|
|
NatKind.Translated => (Outcome.Bad, "NatTranslated"),
|
|
NatKind.NoIpv4 => (Outcome.Info, "NatNoIpv4"),
|
|
_ => (Outcome.Unknown, "NatUnknown"),
|
|
};
|
|
finding.With("FactRouterWan", router?.WanAddress);
|
|
finding.With("FactPublicV4", v4);
|
|
var before = Route.BeforeInternet(hops);
|
|
if (hops.Count > 0) finding.With("FactRoute", string.Join(" → ", before.Select(h => h.Address ?? "*").Append(hops.FirstOrDefault(h => h.Kind == AddressKind.Public)?.Address ?? "…")));
|
|
if (kind == NatKind.Unknown && before.Any(h => h.Kind == AddressKind.Shared)) finding.WithKey("FactHint", "HintSharedHop");
|
|
|
|
var v6Direct = v6 is not null && local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
|
if (v6 is not null) finding.WithKey("FactIpv6", v6Direct ? "ValueIpv6Direct" : "ValueIpv6Translated");
|
|
if (v6Direct && finding.Outcome is Outcome.Bad or Outcome.Unknown && kind != NatKind.NoIpv4)
|
|
{
|
|
finding.Outcome = Outcome.Caution;
|
|
finding.WithKey("FactWayOut", "ValueIpv6Way");
|
|
}
|
|
return (finding, kind);
|
|
}
|
|
|
|
public static NatKind NatOf(LocalSnapshot local, RouterReport router, string v4)
|
|
{
|
|
if (v4 is null) return NatKind.NoIpv4;
|
|
if (local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true) return NatKind.Direct;
|
|
var localV4 = Net.Addresses.Parse(local.LocalV4);
|
|
if (router?.Gateway is null && localV4 is not null && Net.Addresses.Classify(localV4) is AddressKind.Shared or AddressKind.Private && local.Active?.Kind == LinkKind.Mobile)
|
|
return NatKind.Shared;
|
|
var wan = Net.Addresses.Parse(router?.WanAddress);
|
|
if (wan is null && localV4 is not null && Net.Addresses.Classify(localV4) == AddressKind.Shared) return NatKind.Shared;
|
|
if (wan is null) return NatKind.Unknown;
|
|
if (Net.Addresses.SameAddress(router.WanAddress, v4)) return NatKind.HomeRouter;
|
|
return Net.Addresses.Classify(wan) switch
|
|
{
|
|
AddressKind.Shared => NatKind.Shared,
|
|
AddressKind.Private => NatKind.DoubleNat,
|
|
AddressKind.Public => NatKind.Translated,
|
|
_ => NatKind.Unknown,
|
|
};
|
|
}
|
|
|
|
public static Finding Behaviour(StunReport stun)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Behaviour };
|
|
(finding.Outcome, finding.Summary) = stun.Behaviour switch
|
|
{
|
|
NatBehaviour.None => (Outcome.Good, "BehaviourNone"),
|
|
NatBehaviour.Easy => (Outcome.Good, "BehaviourEasy"),
|
|
NatBehaviour.Hard => (Outcome.Caution, "BehaviourHard"),
|
|
_ => (Outcome.Unknown, "BehaviourUnknown"),
|
|
};
|
|
finding.With("FactStunFirst", stun.FirstMapped is null ? null : $"{stun.FirstServer} → {stun.FirstMapped}");
|
|
finding.With("FactStunSecond", stun.SecondMapped is null ? null : $"{stun.SecondServer} → {stun.SecondMapped}");
|
|
finding.With("FactLocalPort", stun.LocalPort > 0 ? stun.LocalPort.ToString(CultureInfo.InvariantCulture) : null);
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Reach(ReachReport reach, RunReport run)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Reach };
|
|
if (reach is null || reach.Choice == ReachChoice.Skip)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "ReachSkipped";
|
|
return finding;
|
|
}
|
|
var refusal = reach.V4Refusal ?? reach.V6Refusal;
|
|
if (reach.V4 is null && reach.V6 is null)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = refusal switch
|
|
{
|
|
"own-network" => "ReachOwnNetwork",
|
|
"too-many" => "ReachTooMany",
|
|
"no-listener" => "ReachNoListener",
|
|
_ => "ReachUnavailable",
|
|
};
|
|
if (refusal == "too-many") finding.Args = [Math.Max(1, (int)Math.Ceiling(reach.RetryAfterSeconds / 60.0)).ToString(CultureInfo.InvariantCulture)];
|
|
return finding;
|
|
}
|
|
|
|
var reached4 = reach.V4?.Ports.Any(p => p.Outcome == ProbeOutcome.Reached) == true;
|
|
var reached6 = reach.V6?.Ports.Any(p => p.Outcome == ProbeOutcome.Reached) == true;
|
|
var mapped = reach.Mappings.Any(m => m.Done);
|
|
(finding.Outcome, finding.Summary) = (reached4, reached6) switch
|
|
{
|
|
(true, true) => (Outcome.Good, "ReachBoth"),
|
|
(true, false) => (Outcome.Good, "ReachV4"),
|
|
(false, true) => (Outcome.Caution, "ReachV6Only"),
|
|
_ when reach.Choice == ReachChoice.OpenPorts && mapped => (Outcome.Bad, "ReachBlockedAfterMapping"),
|
|
_ when reach.Choice == ReachChoice.OpenPorts => (Outcome.Bad, "ReachNoMapping"),
|
|
_ => (Outcome.Caution, "ReachNothingAsIs"),
|
|
};
|
|
foreach (var attempt in reach.Mappings)
|
|
finding.WithKey("FactMapping", attempt.Done ? "ValueMapped" : "ValueNotMapped", attempt.ExternalPort.ToString(CultureInfo.InvariantCulture), attempt.Via.ToString(), attempt.Error.ToString(CultureInfo.InvariantCulture));
|
|
foreach (var port in reach.V4?.Ports ?? [])
|
|
finding.WithKey("FactReachV4", "Probe" + port.Outcome, port.Port.ToString(CultureInfo.InvariantCulture));
|
|
foreach (var port in reach.V6?.Ports ?? [])
|
|
finding.WithKey("FactReachV6", "Probe" + port.Outcome, port.Port.ToString(CultureInfo.InvariantCulture));
|
|
if (reach.V4 is null && reach.V4Refusal is not null) finding.WithKey("FactReachV4", "Refusal" + Pascal(reach.V4Refusal));
|
|
if (reach.V6 is null && reach.V6Refusal is not null) finding.WithKey("FactReachV6", "Refusal" + Pascal(reach.V6Refusal));
|
|
if (reach.Hairpin is not null) finding.WithKey("FactHairpin", reach.Hairpin == true ? "ValueHairpinYes" : "ValueHairpinNo");
|
|
if (reach.V4?.Ports.Any(p => p.Outcome == ProbeOutcome.OpenNotYou && p.Port is 80 or 443) == true && !mapped)
|
|
finding.WithKey("FactHint", "HintSomethingAnswers");
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Mail(Mail.MailReport mail, bool hasV4)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Mail };
|
|
if (!hasV4)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "MailNoIpv4";
|
|
return finding;
|
|
}
|
|
var listed = mail.Listings.Where(l => l.State == Core.Mail.ListingState.Listed && !l.Residential && !l.Wide).ToList();
|
|
var wide = mail.Listings.Where(l => l.State == Core.Mail.ListingState.Listed && l.Wide).ToList();
|
|
(finding.Outcome, finding.Summary) = mail switch
|
|
{
|
|
{ Port25: Core.Mail.PortState.Blocked } => (Outcome.Bad, "MailBlocked"),
|
|
_ when listed.Count > 0 => (Outcome.Bad, "MailListed"),
|
|
_ when mail.Listings.Any(l => l.Residential) => (Outcome.Caution, "MailResidential"),
|
|
_ when wide.Count > 0 => (Outcome.Caution, "MailNetworkListed"),
|
|
{ ReverseName: null } => (Outcome.Caution, "MailNoName"),
|
|
{ ReverseLooksDynamic: true } => (Outcome.Caution, "MailDynamicName"),
|
|
{ Port25: Core.Mail.PortState.Open } => (Outcome.Good, "MailClean"),
|
|
_ => (Outcome.Unknown, "MailUnknown"),
|
|
};
|
|
if (listed.Count > 0) finding.Args = [string.Join(", ", listed.Select(l => l.List))];
|
|
else if (wide.Count > 0) finding.Args = [string.Join(", ", wide.Select(l => l.List))];
|
|
finding.WithKey("FactPort25", mail.Port25 switch
|
|
{
|
|
Core.Mail.PortState.Open => "ValuePort25Open",
|
|
Core.Mail.PortState.Blocked => "ValuePort25Blocked",
|
|
_ => "ValueCantTell",
|
|
}, mail.Banner ?? "");
|
|
if (mail.ReverseName is null) finding.WithKey("FactReverseName", "ValueNoReverse");
|
|
else
|
|
{
|
|
finding.With("FactReverseName", mail.ReverseName);
|
|
finding.WithKey("FactReverseLooks", mail.ReverseLooksDynamic ? "ValueLooksHome" : "ValueLooksServer");
|
|
finding.WithKey("FactReverseConfirmed", mail.ReverseConfirmed ? "ValueYes" : "ValueNo");
|
|
}
|
|
foreach (var listing in mail.Listings)
|
|
finding.Facts.Add(new Fact
|
|
{
|
|
Label = "FactList",
|
|
Key = listing.State switch
|
|
{
|
|
Core.Mail.ListingState.Listed when listing.Residential => "ValueListedHome",
|
|
Core.Mail.ListingState.Listed when listing.Wide => "ValueListedNetwork",
|
|
Core.Mail.ListingState.Listed => "ValueListed",
|
|
Core.Mail.ListingState.Clean => "ValueNotListed",
|
|
_ => "ValueCantTellResolver",
|
|
},
|
|
Args = [listing.List, listing.Code ?? ""],
|
|
});
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Dns(DnsReport dns)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Dns };
|
|
(finding.Outcome, finding.Summary) = dns switch
|
|
{
|
|
{ Hijacks: true } => (Outcome.Bad, "DnsHijack"),
|
|
{ Validates: false } => (Outcome.Caution, "DnsNoValidation"),
|
|
{ Validates: true, Hijacks: false } => (Outcome.Good, "DnsGood"),
|
|
_ => (Outcome.Unknown, "DnsUnknown"),
|
|
};
|
|
if (dns.Resolvers.Count > 0) finding.With("FactDns", string.Join(", ", dns.Resolvers.Take(3)));
|
|
if (dns.Validates is not null) finding.WithKey("FactDnssec", dns.Validates == true ? "ValueDnssecYes" : "ValueDnssecNo");
|
|
if (dns.Hijacks is not null) finding.WithKey("FactMissingNames", dns.Hijacks == true ? "ValueHijacked" : "ValueAnsweredMissing");
|
|
if (dns.Ipv6Names is not null) finding.WithKey("FactIpv6Names", dns.Ipv6Names == true ? "ValueYes" : "ValueNo");
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Speed(SpeedReport speed)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Speed };
|
|
if (speed.Limited)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "SpeedLimited";
|
|
return finding;
|
|
}
|
|
if (speed.DownMbps <= 0)
|
|
{
|
|
finding.Outcome = Outcome.Unknown;
|
|
finding.Summary = "SpeedFailed";
|
|
return finding;
|
|
}
|
|
finding.Outcome = Outcome.Info;
|
|
finding.Summary = "SpeedResult";
|
|
finding.Args = [speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture), speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture)];
|
|
finding.WithKey("FactDownload", "ValueMbpsDecimal", speed.DownMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
|
finding.WithKey("FactUpload", "ValueMbpsDecimal", speed.UpMbps.ToString("0.#", CultureInfo.InvariantCulture));
|
|
finding.WithKey("FactSample", "ValueSample", (speed.DownBytes / 1048576).ToString(CultureInfo.InvariantCulture), (speed.UpBytes / 1048576).ToString(CultureInfo.InvariantCulture));
|
|
return finding;
|
|
}
|
|
|
|
public static Finding Device(DeviceReport device)
|
|
{
|
|
var finding = new Finding { Id = CheckId.Device };
|
|
var small = device.Cores < 2 || device.MemoryMb < 1800 || device.FreeDiskGb is > 0 and < 20;
|
|
(finding.Outcome, finding.Summary) = device switch
|
|
{
|
|
{ Kind: DeviceKind.Phone } => (Outcome.Caution, "DevicePhone"),
|
|
_ when small => (Outcome.Caution, "DeviceSmall"),
|
|
{ OnBattery: true } => (Outcome.Info, "DeviceLaptop"),
|
|
_ => (Outcome.Good, "DeviceGood"),
|
|
};
|
|
finding.With("FactSystem", device.System);
|
|
finding.WithKey("FactCores", "ValueCount", device.Cores.ToString(CultureInfo.InvariantCulture));
|
|
finding.WithKey("FactMemory", "ValueGb", Math.Round(device.MemoryMb / 1024.0, 1).ToString("0.#", CultureInfo.InvariantCulture));
|
|
if (device.FreeDiskGb > 0) finding.WithKey("FactDisk", "ValueGbFree", device.FreeDiskGb.ToString(CultureInfo.InvariantCulture));
|
|
if (device.OnBattery is not null) finding.WithKey("FactBattery", device.OnBattery == true ? "ValueYes" : "ValueNo");
|
|
return finding;
|
|
}
|
|
|
|
public static Finding History(HistorySummary summary)
|
|
{
|
|
var finding = new Finding { Id = CheckId.History, Outcome = Outcome.Info };
|
|
var since = summary.Since.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
|
if (summary.Checks <= 1)
|
|
{
|
|
finding.Summary = "HistoryFirst";
|
|
return finding;
|
|
}
|
|
finding.Summary = summary.Changes == 0 ? "HistoryStable" : "HistoryChanged";
|
|
finding.Args = [summary.Checks.ToString(CultureInfo.InvariantCulture), since, summary.Changes.ToString(CultureInfo.InvariantCulture)];
|
|
finding.WithKey("FactChecks", "ValueCount", summary.Checks.ToString(CultureInfo.InvariantCulture));
|
|
finding.Facts.Add(new Fact { Label = "FactSince", Date = since });
|
|
finding.WithKey("FactAddressesSeen", "ValueCount", summary.AddressesSeen.ToString(CultureInfo.InvariantCulture));
|
|
return finding;
|
|
}
|
|
|
|
static void AddFact(Finding finding, string label, string prefix, ProviderFact fact)
|
|
{
|
|
if (fact?.Value is null) return;
|
|
finding.Facts.Add(new Fact
|
|
{
|
|
Label = label,
|
|
Key = prefix + Pascal(fact.Value),
|
|
Amount = fact.Price,
|
|
Currency = fact.Currency,
|
|
Link = fact.Source,
|
|
Basis = fact.Basis is null ? null : "Basis" + Pascal(fact.Basis),
|
|
Date = fact.Date,
|
|
});
|
|
}
|
|
|
|
static string AnswerKey(Answer answer) => answer switch
|
|
{
|
|
Answer.Answered => "ValueAnswered",
|
|
Answer.Refused => "ValueRefused",
|
|
_ => "ValueSilent",
|
|
};
|
|
|
|
static string Display(AsnEntry entry) => string.IsNullOrWhiteSpace(entry.Name) ? $"AS{entry.Number}" : entry.Name;
|
|
|
|
static string Describe(AsnEntry entry) => $"{Display(entry)} (AS{entry.Number}{(string.IsNullOrEmpty(entry.Country) || entry.Country == "None" ? "" : ", " + entry.Country)})";
|
|
|
|
static string Join(string make, string label)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(make)) return label;
|
|
if (string.IsNullOrWhiteSpace(label) || label.Contains(make.Split(' ')[0], StringComparison.OrdinalIgnoreCase)) return label ?? make;
|
|
return $"{make} {label}";
|
|
}
|
|
|
|
static string Pascal(string value) => string.Concat(value.Split('-').Select(p => p.Length == 0 ? p : char.ToUpperInvariant(p[0]) + p[1..]));
|
|
}
|