feat: support long text and safe attachment batches
Build web service image / build (push) Successful in 1m15s
Build web service image / build (push) Successful in 1m15s
This commit is contained in:
@@ -334,6 +334,21 @@ public sealed class RemoteQueueAndLedgerTests
|
||||
var scope = Assert.Single(pending.ReportingScopes);
|
||||
Assert.Equal("chat-a", scope.ChatId);
|
||||
Assert.Equal(ReportingChatType.Private, scope.ChatType);
|
||||
|
||||
reloaded.MarkResultTaskNotFound(pending.Result.TaskId);
|
||||
Assert.Empty(reloaded.UnreportedResults());
|
||||
Assert.Empty(reloaded.UnreportedResultsWithScopes());
|
||||
Assert.Equal(1, reloaded.UnreportableResultCount());
|
||||
Assert.True(reloaded.TryGet("task-read", out var retained));
|
||||
Assert.NotNull(retained);
|
||||
Assert.False(retained.Reported);
|
||||
Assert.Equal("TaskNotFound", retained.ResultReportErrorCode);
|
||||
Assert.NotNull(retained.Result?.Content);
|
||||
Assert.Single(retained.ReportingScopes);
|
||||
|
||||
var terminal = new RemoteTaskLedger(path);
|
||||
Assert.Equal(1, terminal.UnreportableResultCount());
|
||||
Assert.Empty(terminal.UnreportedResultsWithScopes());
|
||||
}
|
||||
finally { directory.Delete(true); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatAttachmentInputTests
|
||||
{
|
||||
[Fact]
|
||||
public void PrepareFilePathsRejectsOutOfRangeBatchesBeforeAnySend()
|
||||
{
|
||||
var single = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(["one.txt"]));
|
||||
var tooMany = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(
|
||||
Enumerable.Repeat("unused.txt", WechatAttachmentInput.MaxBatchSize + 1).ToArray()));
|
||||
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, single.Code);
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, tooMany.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareFilePathsValidatesEveryFileBeforeReturningCanonicalPaths()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(directory.FullName, "first.pdf");
|
||||
var second = Path.Combine(directory.FullName, "second.png");
|
||||
File.WriteAllText(first, "first");
|
||||
File.WriteAllText(second, "second");
|
||||
|
||||
var paths = WechatAttachmentInput.PrepareFilePaths([first, second]);
|
||||
|
||||
Assert.Equal([Path.GetFullPath(first), Path.GetFullPath(second)], paths);
|
||||
Assert.True(WechatAttachmentInput.IsImagePath(second));
|
||||
Assert.True(WechatAttachmentInput.IsImagePath("photo.JPEG"));
|
||||
Assert.False(WechatAttachmentInput.IsImagePath(first));
|
||||
}
|
||||
finally
|
||||
{
|
||||
directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareFilePathsRejectsASecondMissingFileBeforeSendingTheFirst()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var first = Path.Combine(directory.FullName, "first.pdf");
|
||||
File.WriteAllText(first, "first");
|
||||
|
||||
var exception = Assert.Throws<WxAgentException>(() => WechatAttachmentInput.PrepareFilePaths(
|
||||
[first, Path.Combine(directory.FullName, "missing.pdf")]));
|
||||
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ public sealed class WechatMentionConfirmationTests
|
||||
public void RequiresFreshExactMentionAndBody(string text, string member, bool expected)
|
||||
{
|
||||
var message = new ChatMessageSnapshot(text, "fresh", 0, ChatMessageType.Text);
|
||||
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, new HashSet<string>(), member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message, new HashSet<string> { "fresh" }, member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, new HashSet<string>(), member, "marker"));
|
||||
var empty = new Dictionary<string, int>();
|
||||
var current = new Dictionary<string, int> { ["fresh"] = 1 };
|
||||
Assert.Equal(expected, WechatSendConfirmation.MatchesMention(message, empty, current, member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message, current, current, member, "marker"));
|
||||
Assert.False(WechatSendConfirmation.MatchesMention(message with { Type = ChatMessageType.Quote }, empty, current, member, "marker"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatMentionOptionSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectsOnlyAUniqueVisibleOptionForAUniqueDatabaseDisplayName()
|
||||
{
|
||||
string?[] members = ["Alice", "Bob"];
|
||||
(string? Name, bool IsOffscreen, bool HasBounds)[] options =
|
||||
[
|
||||
("Alice", false, true),
|
||||
("Bob", false, true)
|
||||
];
|
||||
|
||||
Assert.Equal(0, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDatabaseDuplicatesEvenWhenOnlyOneCandidateIsVisible()
|
||||
{
|
||||
string?[] members = ["Alice", "Alice"];
|
||||
(string? Name, bool IsOffscreen, bool HasBounds)[] options = [("Alice", false, true)];
|
||||
|
||||
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, options, "Alice"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsOffscreenOrDuplicateUiCandidates()
|
||||
{
|
||||
string?[] members = ["Alice"];
|
||||
(string? Name, bool IsOffscreen, bool HasBounds)[] offscreen = [("Alice", true, false)];
|
||||
(string? Name, bool IsOffscreen, bool HasBounds)[] duplicate = [("Alice", false, true), ("Alice", true, false)];
|
||||
|
||||
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, offscreen, "Alice"));
|
||||
Assert.Equal(-1, WechatMentionOptionSelection.FindUniqueVisibleOptionIndex(members, duplicate, "Alice"));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,27 @@ public sealed class WechatMergedChatTests
|
||||
Assert.DoesNotContain("must-not-be-exported", JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesMixedImageVideoFileAndNestedAttachmentMetadata()
|
||||
{
|
||||
static XElement Attachment(int type, string title, string format, long size) => new("dataitem",
|
||||
new XAttribute("datatype", type), new XElement("datatitle", title), new XElement("datafmt", format), new XElement("datasize", size));
|
||||
|
||||
var image = Attachment(2, "image.jpg", "jpg", 12);
|
||||
var video = Attachment(4, "clip.mp4", "mp4", 34);
|
||||
var file = Attachment(8, "notes.pdf", "pdf", 56);
|
||||
var nested = new XElement("dataitem", new XAttribute("datatype", 17), Record(Attachment(2, "nested.png", "png", 78)));
|
||||
|
||||
var result = WechatMergedChatParser.Parse(Record(image, video, file, nested).ToString());
|
||||
|
||||
Assert.Equal([2, 4, 8, 17], result.Messages.Select(message => message.DataType));
|
||||
Assert.Equal([12L, 34L, 56L], result.Messages.Take(3).Select(message => message.SizeBytes));
|
||||
Assert.Equal(["image.jpg", "clip.mp4", "notes.pdf"], result.Messages.Take(3).Select(message => message.Title));
|
||||
var nestedMessage = result.Messages[3].NestedRecord!.Messages[0];
|
||||
Assert.Equal("3/0", nestedMessage.Path);
|
||||
Assert.Equal(2, nestedMessage.DataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsTruncationMissingPayloadAndInvalidTimestamp()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatSendConfirmationOccurrenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdenticalFingerprintIsNewOnlyWhenItsVisibleCountIncreases()
|
||||
{
|
||||
var baseline = new Dictionary<string, int> { ["same-message"] = 1 };
|
||||
var unchanged = new Dictionary<string, int> { ["same-message"] = 1 };
|
||||
var appended = new Dictionary<string, int> { ["same-message"] = 2 };
|
||||
|
||||
Assert.False(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, unchanged));
|
||||
Assert.True(WechatSendConfirmation.IsNewOccurrence("same-message", baseline, appended));
|
||||
Assert.False(WechatSendConfirmation.IsNewOccurrence("other-message", baseline, appended));
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ public sealed class WechatSendConfirmationTests
|
||||
[Fact]
|
||||
public void RequiresFreshMatchingTypeAndExactReplyText()
|
||||
{
|
||||
var baseline = new HashSet<string> { "old" };
|
||||
var baseline = new Dictionary<string, int> { ["old"] = 1 };
|
||||
var current = new Dictionary<string, int> { ["old"] = 1, ["new"] = 1 };
|
||||
var reply = new ChatMessageSnapshot("reply", "new", 0, ChatMessageType.Quote);
|
||||
Assert.True(WechatSendConfirmation.Matches(reply, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, ChatMessageType.Quote, "reply"));
|
||||
Assert.True(WechatSendConfirmation.Matches(reply, baseline, current, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Fingerprint = "old" }, baseline, current, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Type = ChatMessageType.Text }, baseline, current, ChatMessageType.Quote, "reply"));
|
||||
Assert.False(WechatSendConfirmation.Matches(reply with { Text = "another reply" }, baseline, current, ChatMessageType.Quote, "reply"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -25,15 +26,18 @@ public sealed class WechatSendConfirmationTests
|
||||
public void FileConfirmationRequiresExactFilenameLine(string text, bool expected)
|
||||
{
|
||||
var message = new ChatMessageSnapshot(text, "new", 0, ChatMessageType.File);
|
||||
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.File, "report.txt"));
|
||||
Assert.Equal(expected, WechatSendConfirmation.Matches(message, new Dictionary<string, int>(),
|
||||
new Dictionary<string, int> { ["new"] = 1 }, ChatMessageType.File, "report.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImageConfirmationRejectsOtherTypesAndOldRows()
|
||||
{
|
||||
var message = new ChatMessageSnapshot("[图片]", "new", 0, ChatMessageType.Image);
|
||||
Assert.True(WechatSendConfirmation.Matches(message, new HashSet<string>(), ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message, new HashSet<string> { "new" }, ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, new HashSet<string>(), ChatMessageType.Image, null));
|
||||
var empty = new Dictionary<string, int>();
|
||||
var current = new Dictionary<string, int> { ["new"] = 1 };
|
||||
Assert.True(WechatSendConfirmation.Matches(message, empty, current, ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message, current, current, ChatMessageType.Image, null));
|
||||
Assert.False(WechatSendConfirmation.Matches(message with { Type = ChatMessageType.Text }, empty, current, ChatMessageType.Image, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using WxAgent.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace WxAgent.Core.Tests;
|
||||
|
||||
public sealed class WechatTextInputSegmentationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SplitForSendSplitsLongTextAtTheSingleMessageBoundary()
|
||||
{
|
||||
var text = new string('x', WechatTextInput.MaxSegmentLength + 1);
|
||||
|
||||
var segments = WechatTextInput.SplitForSend(text);
|
||||
|
||||
Assert.Equal(2, segments.Count);
|
||||
Assert.Equal(WechatTextInput.MaxSegmentLength, segments[0].Length);
|
||||
Assert.Equal(1, segments[1].Length);
|
||||
Assert.Equal(text, string.Concat(segments));
|
||||
Assert.Throws<WxAgentException>(() => WechatTextInput.Prepare(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitForSendDoesNotBreakACombiningTextElement()
|
||||
{
|
||||
var prefix = new string('x', WechatTextInput.MaxSegmentLength - 1);
|
||||
var combiningElement = "e\u0301";
|
||||
var text = prefix + combiningElement + "tail";
|
||||
|
||||
var segments = WechatTextInput.SplitForSend(text);
|
||||
|
||||
Assert.Equal(2, segments.Count);
|
||||
Assert.Equal(prefix, segments[0]);
|
||||
Assert.StartsWith(combiningElement, segments[1], StringComparison.Ordinal);
|
||||
Assert.All(segments, segment => Assert.InRange(segment.Length, 1, WechatTextInput.MaxSegmentLength));
|
||||
Assert.Equal(text, string.Concat(segments));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitForSendRejectsTextBeyondTheBoundedTotalLength()
|
||||
{
|
||||
var text = new string('x', WechatTextInput.MaxTextLength + 1);
|
||||
|
||||
var exception = Assert.Throws<WxAgentException>(() => WechatTextInput.SplitForSend(text));
|
||||
|
||||
Assert.Equal(WxAgentErrorCode.InvalidArgument, exception.Code);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ public sealed class WechatTextInputTests
|
||||
var parsed = Assert.Single(VisibleMessageParser.Parse([reply + "\n引用 Alice 的消息 : original"]));
|
||||
Assert.Equal(reply, parsed.Text);
|
||||
Assert.Equal(ChatMessageType.Quote, parsed.Type);
|
||||
Assert.True(WechatSendConfirmation.Matches(parsed, new HashSet<string>(), ChatMessageType.Quote, reply));
|
||||
Assert.True(WechatSendConfirmation.Matches(parsed, new Dictionary<string, int>(),
|
||||
new Dictionary<string, int> { [parsed.Fingerprint] = 1 }, ChatMessageType.Quote, reply));
|
||||
Assert.Equal("original", parsed.Quote!.Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class BroadcastOperationTests
|
||||
private sealed class Backend(bool failSecond = false) : IAgentBackend
|
||||
{
|
||||
public List<string> SentTargets { get; } = [];
|
||||
public List<string> SentTexts { get; } = [];
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities =>
|
||||
[
|
||||
@@ -35,6 +36,7 @@ public sealed class BroadcastOperationTests
|
||||
if (failSecond && targetId == "session-b")
|
||||
throw new ServiceException("TargetUnavailable", 409, "Target is no longer available.");
|
||||
SentTargets.Add(targetId);
|
||||
SentTexts.Add(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +138,53 @@ public sealed class BroadcastOperationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTextAcceptsBoundedLongTextWhileBroadcastRetainsSingleMessageLimit()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir);
|
||||
var token = new string('F', 43);
|
||||
var options = new ServiceOptions { CredentialFile = Path.Combine(dir, "credentials.json"), DataDirectory = dir };
|
||||
File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[]
|
||||
{
|
||||
new ServiceCredential("p", ServiceOptions.HashToken(token), ["read", "write"], [])
|
||||
}));
|
||||
var backend = new Backend();
|
||||
await using var app = ServiceHost.Build(options, backend, b => b.WebHost.UseTestServer());
|
||||
try
|
||||
{
|
||||
await app.StartAsync();
|
||||
using var client = app.GetTestClient();
|
||||
client.BaseAddress = new Uri("http://localhost:5088");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var longText = new string('x', WxAgent.Core.WechatTextInput.MaxSegmentLength + 1);
|
||||
var submitted = await client.PostAsJsonAsync("/api/v1/operations", new
|
||||
{
|
||||
kind = "send-text", accountId = "account-1", targetId = "session-a", text = longText,
|
||||
idempotencyKey = "send-long-text-1", confirmed = true
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, submitted.StatusCode);
|
||||
var queued = await submitted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var completed = await WaitForTerminalAsync(client, queued.GetProperty("id").GetString()!);
|
||||
Assert.Equal("Succeeded", completed.GetProperty("state").GetString());
|
||||
Assert.Equal(["session-a"], backend.SentTargets);
|
||||
Assert.Equal([longText], backend.SentTexts);
|
||||
|
||||
var broadcast = await client.PostAsJsonAsync("/api/v1/operations", new
|
||||
{
|
||||
kind = "broadcast-text", accountId = "account-1", targets = new[] { "session-a" }, text = longText,
|
||||
idempotencyKey = "broadcast-long-text-1", confirmed = true, stopOnError = true
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, broadcast.StatusCode);
|
||||
Assert.Single(backend.SentTargets);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, string operationId)
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
|
||||
Reference in New Issue
Block a user