diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 331746e00..5b0cc0c5c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,12 +14,12 @@ jobs:
runs-on: windows-2022
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1.1
+ uses: microsoft/setup-msbuild@v1
- name: Set configuration env
shell: pwsh
@@ -43,7 +43,7 @@ jobs:
msbuild /target:restore,build "/p:Configuration=$($env:CONFIGURATION)" /verbosity:minimal ${{ inputs.msbuild_args }}
- name: Upload build result
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: build
path: |
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index aeda25940..f55909257 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -20,12 +20,12 @@ jobs:
needs: [build]
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: '${{ github.event.pull_request.head.sha }}'
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1.1
+ uses: microsoft/setup-msbuild@v1
- name: Set configuration env
shell: pwsh
@@ -37,7 +37,7 @@ jobs:
}
- name: Restore build result
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
name: build
@@ -50,7 +50,7 @@ jobs:
.\tools\build-zip-archive.ps1 -BinDir $binDir -DestPath $destPath
- name: Upload build result
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: package
path: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index dcfad8c23..a7d1d159a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -17,12 +17,12 @@ jobs:
needs: [build]
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Add msbuild to PATH
- uses: microsoft/setup-msbuild@v1.1
+ uses: microsoft/setup-msbuild@v1
- name: Set configuration env
shell: pwsh
@@ -41,7 +41,7 @@ jobs:
nuget-
- name: Restore build result
- uses: actions/download-artifact@v3
+ uses: actions/download-artifact@v4
with:
name: build
@@ -96,7 +96,7 @@ jobs:
exit $p.ExitCode
}
- - uses: codecov/codecov-action@v3
+ - uses: codecov/codecov-action@v4.0.0-beta.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 57496d110..b8f640b80 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,5 +1,8 @@
更新履歴
+==== Ver 3.12.0(2024/01/20)
+ * NEW: graphqlエンドポイントを使用したホームタイムラインの取得に対応
+
==== Ver 3.11.0(2024/01/07)
* NEW: Cookie使用時の関連発言表示に対応
* FIX: APIリクエストのタイムアウト時に接続が切断されない場合がある不具合を修正
diff --git a/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs b/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs
new file mode 100644
index 000000000..df1fb6efe
--- /dev/null
+++ b/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs
@@ -0,0 +1,96 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2023 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Threading.Tasks;
+using Moq;
+using OpenTween.Connection;
+using Xunit;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class HomeLatestTimelineRequestTest
+ {
+ [Fact]
+ public async Task Send_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/HomeLatestTimeline.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/lAKISuk_McyDUlhS2Zmv4A/HomeLatestTimeline"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal(2, query.Count);
+ Assert.Equal("""{"includePromotedContent":true,"latestControlAvailable":true,"requestContext":"launch","count":20}""", query["variables"]);
+ Assert.True(query.ContainsKey("features"));
+ Assert.Equal("HomeLatestTimeline", request.EndpointName);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new HomeLatestTimelineRequest
+ {
+ Count = 20,
+ };
+
+ var response = await request.Send(mock.Object);
+ Assert.Single(response.Tweets);
+ Assert.Equal("DAABCgABGENe-W5AJxEKAAIWeWboXhcQAAgAAwAAAAEAAA", response.CursorTop);
+ Assert.Equal("DAABCgABGENe-W4__5oKAAIWK_5v3BcQAAgAAwAAAAIAAA", response.CursorBottom);
+
+ mock.VerifyAll();
+ }
+
+ [Fact]
+ public async Task Send_RequestCursor_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/HomeLatestTimeline.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/lAKISuk_McyDUlhS2Zmv4A/HomeLatestTimeline"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal(2, query.Count);
+ Assert.Equal("""{"includePromotedContent":true,"latestControlAvailable":true,"requestContext":"launch","count":20,"cursor":"aaa"}""", query["variables"]);
+ Assert.True(query.ContainsKey("features"));
+ Assert.Equal("HomeLatestTimeline", request.EndpointName);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new HomeLatestTimelineRequest
+ {
+ Count = 20,
+ Cursor = "aaa",
+ };
+
+ await request.Send(mock.Object);
+ mock.VerifyAll();
+ }
+ }
+}
diff --git a/OpenTween.Tests/DetailsListViewTest.cs b/OpenTween.Tests/DetailsListViewTest.cs
index d890a1499..3250c7dec 100644
--- a/OpenTween.Tests/DetailsListViewTest.cs
+++ b/OpenTween.Tests/DetailsListViewTest.cs
@@ -20,10 +20,7 @@
// Boston, MA 02110-1301, USA.
using System;
-using System.Collections.Generic;
using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using OpenTween.OpenTweenCustomControl;
using Xunit;
@@ -36,5 +33,110 @@ public void Initialize_Test()
{
using var listView = new DetailsListView();
}
+
+ [WinFormsFact]
+ public void SelectionMark_Test()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ listView.SelectionMark = 3;
+ Assert.Equal(3, listView.SelectionMark);
+ }
+
+ [WinFormsFact]
+ public void SelectItems_EmptyTest()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ listView.SelectedIndices.Add(1);
+ Assert.Single(listView.SelectedIndices);
+
+ listView.SelectItems(Array.Empty());
+ Assert.Empty(listView.SelectedIndices);
+ }
+
+ [WinFormsFact]
+ public void SelectItems_SingleTest()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ listView.SelectedIndices.Add(1);
+ Assert.Single(listView.SelectedIndices);
+
+ listView.SelectItems(new[] { 2 });
+ Assert.Equal(new[] { 2 }, listView.SelectedIndices.Cast());
+ }
+
+ [WinFormsFact]
+ public void SelectItems_Multiple_ClearAndSelectTest()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ listView.SelectedIndices.Add(2);
+ listView.SelectedIndices.Add(3);
+ Assert.Equal(2, listView.SelectedIndices.Count);
+
+ // Clear して選択し直した方が早いパターン
+ listView.SelectItems(new[] { 5, 6 });
+ Assert.Equal(new[] { 5, 6 }, listView.SelectedIndices.Cast());
+ }
+
+ [WinFormsFact]
+ public void SelectItems_Multiple_DeselectAndSelectTest()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ listView.SelectedIndices.Add(1);
+ listView.SelectedIndices.Add(2);
+ listView.SelectedIndices.Add(3);
+ Assert.Equal(3, listView.SelectedIndices.Count);
+
+ // 選択範囲の差分だけ更新した方が早いパターン
+ listView.SelectItems(new[] { 2, 3, 4 });
+ Assert.Equal(new[] { 2, 3, 4 }, listView.SelectedIndices.Cast());
+ }
+
+ [WinFormsFact]
+ public void SelectItems_OutOfRangeTest()
+ {
+ using var listView = new DetailsListView();
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new();
+ listView.VirtualMode = true;
+ listView.VirtualListSize = 10;
+ listView.CreateControl();
+
+ Assert.Throws(
+ () => listView.SelectItems(new[] { -1 })
+ );
+ Assert.Throws(
+ () => listView.SelectItems(new[] { 10 })
+ );
+ }
}
}
diff --git a/OpenTween.Tests/ListElementTest.cs b/OpenTween.Tests/ListElementTest.cs
new file mode 100644
index 000000000..cba9f40c5
--- /dev/null
+++ b/OpenTween.Tests/ListElementTest.cs
@@ -0,0 +1,54 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using Xunit;
+
+namespace OpenTween
+{
+ public class ListElementTest
+ {
+ [Fact]
+ public void ToString_PublicTest()
+ {
+ var list = new ListElement
+ {
+ Id = 12345L,
+ Name = "tetete",
+ Username = "opentween",
+ IsPublic = true,
+ };
+ Assert.Equal("@opentween/tetete [Public]", list.ToString());
+ }
+
+ [Fact]
+ public void ToString_ProtectedTest()
+ {
+ var list = new ListElement
+ {
+ Id = 12345L,
+ Name = "tetete",
+ Username = "opentween",
+ IsPublic = false,
+ };
+ Assert.Equal("@opentween/tetete [Protected]", list.ToString());
+ }
+ }
+}
diff --git a/OpenTween.Tests/Models/DetailsHtmlBuilderTest.cs b/OpenTween.Tests/Models/DetailsHtmlBuilderTest.cs
new file mode 100644
index 000000000..6ba0287e9
--- /dev/null
+++ b/OpenTween.Tests/Models/DetailsHtmlBuilderTest.cs
@@ -0,0 +1,45 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Xunit;
+
+namespace OpenTween.Models
+{
+ public class DetailsHtmlBuilderTest
+ {
+ [Fact]
+ public void Build_Test()
+ {
+ var settingCommon = new SettingCommon();
+ var settingLocal = new SettingLocal();
+ using var theme = new ThemeManager(settingLocal);
+
+ var builder = new DetailsHtmlBuilder();
+ builder.Prepare(settingCommon, theme);
+
+ var actualHtml = builder.Build("tetete");
+ var parsedDocument = XDocument.Parse(actualHtml);
+ Assert.Equal("tetete", parsedDocument.XPathSelectElement("/html/body/p").Value);
+ }
+ }
+}
diff --git a/OpenTween.Tests/Models/StatusTextHistoryTest.cs b/OpenTween.Tests/Models/StatusTextHistoryTest.cs
new file mode 100644
index 000000000..01e240088
--- /dev/null
+++ b/OpenTween.Tests/Models/StatusTextHistoryTest.cs
@@ -0,0 +1,121 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using Xunit;
+
+namespace OpenTween.Models
+{
+ public class StatusTextHistoryTest
+ {
+ [Fact]
+ public void Initialize_Test()
+ {
+ var history = new StatusTextHistory();
+ Assert.Single(history.Items);
+ Assert.Equal(new("", null), history.Items[0]);
+ Assert.Equal(0, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void SetCurrentItem_Test()
+ {
+ var history = new StatusTextHistory();
+ history.SetCurrentItem("@hoge aaa", (new TwitterStatusId("111"), "hoge"));
+ Assert.Single(history.Items);
+ Assert.Equal(new("@hoge aaa", (new TwitterStatusId("111"), "hoge")), history.Items[0]);
+ Assert.Equal(0, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void Back_NoItemsTest()
+ {
+ var history = new StatusTextHistory();
+ history.Back();
+ Assert.Single(history.Items);
+ Assert.Equal(new("", null), history.Items[0]);
+ Assert.Equal(0, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void Back_HasItemsTest()
+ {
+ var history = new StatusTextHistory();
+ history.AddLast("@hoge aaa", (new TwitterStatusId("111"), "hoge"));
+ history.Back();
+
+ Assert.Equal(2, history.Items.Count);
+ Assert.Equal(new("@hoge aaa", (new TwitterStatusId("111"), "hoge")), history.Items[0]);
+ Assert.Equal(new("", null), history.Items[1]);
+ Assert.Equal(0, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void Forward_NoItemsTest()
+ {
+ var history = new StatusTextHistory();
+ history.Forward();
+ Assert.Single(history.Items);
+ Assert.Equal(new("", null), history.Items[0]);
+ Assert.Equal(0, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void Forward_HasItemsTest()
+ {
+ var history = new StatusTextHistory();
+ history.AddLast("@hoge aaa", (new TwitterStatusId("111"), "hoge"));
+ history.Back();
+ history.Forward();
+
+ Assert.Equal(2, history.Items.Count);
+ Assert.Equal(new("@hoge aaa", (new TwitterStatusId("111"), "hoge")), history.Items[0]);
+ Assert.Equal(new("", null), history.Items[1]);
+ Assert.Equal(1, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void AddLast_Test()
+ {
+ var history = new StatusTextHistory();
+ history.AddLast("@hoge aaa", (new TwitterStatusId("111"), "hoge"));
+ Assert.Equal(2, history.Items.Count);
+ Assert.Equal(new("@hoge aaa", (new TwitterStatusId("111"), "hoge")), history.Items[0]);
+ Assert.Equal(new("", null), history.Items[1]);
+ Assert.Equal(1, history.HistoryIndex);
+ }
+
+ [Fact]
+ public void Peek_EmptyTest()
+ {
+ var history = new StatusTextHistory();
+ Assert.Null(history.Peek());
+ }
+
+ [Fact]
+ public void Peek_HasItemsTest()
+ {
+ var history = new StatusTextHistory();
+ history.AddLast("@hoge aaa", (new TwitterStatusId("111"), "hoge"));
+
+ Assert.Equal(new("@hoge aaa", (new TwitterStatusId("111"), "hoge")), history.Peek());
+ }
+ }
+}
diff --git a/OpenTween.Tests/Models/TabModelTest.cs b/OpenTween.Tests/Models/TabModelTest.cs
index f256d15aa..df6cd6263 100644
--- a/OpenTween.Tests/Models/TabModelTest.cs
+++ b/OpenTween.Tests/Models/TabModelTest.cs
@@ -673,6 +673,88 @@ public void OnFilterModified_DetachedTest()
Assert.False(tab.FilterModified);
}
+ [Fact]
+ public void IndexOf_SingleFoundTest()
+ {
+ var tab = new PublicSearchTabModel("search");
+
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("100"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0),
+ TextFromApi = "aaa",
+ });
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ tab.AddSubmit();
+
+ Assert.Equal(0, tab.IndexOf(new TwitterStatusId("100")));
+ }
+
+ [Fact]
+ public void IndexOf_SingleNotFoundTest()
+ {
+ var tab = new PublicSearchTabModel("search");
+
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("100"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0),
+ TextFromApi = "aaa",
+ });
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ tab.AddSubmit();
+
+ Assert.Equal(-1, tab.IndexOf(new TwitterStatusId("200")));
+ }
+
+ [Fact]
+ public void IndexOf_MultipleFoundTest()
+ {
+ var tab = new PublicSearchTabModel("search");
+
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("100"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0),
+ TextFromApi = "aaa",
+ });
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("200"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1),
+ TextFromApi = "bbb",
+ });
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ tab.AddSubmit();
+
+ var actual = tab.IndexOf(new[] { new TwitterStatusId("200"), new TwitterStatusId("100") });
+ Assert.Equal(new[] { 1, 0 }, actual);
+ }
+
+ [Fact]
+ public void IndexOf_MultiplePartiallyFoundTest()
+ {
+ var tab = new PublicSearchTabModel("search");
+
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("100"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0),
+ TextFromApi = "aaa",
+ });
+ tab.AddPostQueue(new()
+ {
+ StatusId = new TwitterStatusId("200"),
+ CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1),
+ TextFromApi = "bbb",
+ });
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ tab.AddSubmit();
+
+ var actual = tab.IndexOf(new[] { new TwitterStatusId("100"), new TwitterStatusId("999") });
+ Assert.Equal(new[] { 0, -1 }, actual);
+ }
+
[Fact]
public void SearchPostsAll_Test()
{
diff --git a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs
index 88718fd24..6a31f012b 100644
--- a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs
+++ b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs
@@ -286,6 +286,27 @@ public void CreateFromDirectMessageEvent_SenderTest()
Assert.True(post.IsMe);
}
+ [Fact]
+ public void GetReceivedHashtags_Test()
+ {
+ var factory = new TwitterPostFactory(this.CreateTabinfo());
+ var status = this.CreateStatus();
+ status.FullText = "hoge #OpenTween";
+ status.Entities.Hashtags = new[]
+ {
+ new TwitterEntityHashtag
+ {
+ Indices = new[] { 5, 15 },
+ Text = "OpenTween",
+ },
+ };
+
+ _ = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds: EmptyIdSet);
+
+ Assert.Equal(new[] { "#OpenTween" }, factory.GetReceivedHashtags());
+ Assert.Empty(factory.GetReceivedHashtags());
+ }
+
[Fact]
public void CreateFromStatus_MediaAltTest()
{
diff --git a/OpenTween.Tests/Resources/Responses/HomeLatestTimeline.json b/OpenTween.Tests/Resources/Responses/HomeLatestTimeline.json
new file mode 100644
index 000000000..ff7991c9a
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/HomeLatestTimeline.json
@@ -0,0 +1,654 @@
+{
+ "data": {
+ "home": {
+ "home_timeline_urt": {
+ "instructions": [
+ {
+ "type": "TimelineAddEntries",
+ "entries": [
+ {
+ "entryId": "tweet-1619438689213419520",
+ "sortIndex": "1748345505739440127",
+ "content": {
+ "entryType": "TimelineTimelineItem",
+ "__typename": "TimelineTimelineItem",
+ "itemContent": {
+ "itemType": "TimelineTweet",
+ "__typename": "TimelineTweet",
+ "tweet_results": {
+ "result": {
+ "__typename": "Tweet",
+ "rest_id": "1619438689213419520",
+ "core": {
+ "user_results": {
+ "result": {
+ "__typename": "User",
+ "id": "VXNlcjo3NzE4NzExMjQ=",
+ "rest_id": "771871124",
+ "affiliates_highlighted_label": {
+ "label": {
+ "badge": {
+ "url": "https://pbs.twimg.com/semantic_core_img/1428827730364096519/4ZXpTBhS?format=png&name=orig"
+ },
+ "description": "Automated",
+ "longDescription": {
+ "text": "Automated by @opentween",
+ "entities": [
+ {
+ "fromIndex": 13,
+ "toIndex": 23,
+ "ref": {
+ "type": "TimelineRichTextMention",
+ "screen_name": "opentween",
+ "mention_results": {
+ "result": {
+ "__typename": "User",
+ "legacy": {
+ "screen_name": "opentween"
+ },
+ "rest_id": "514241801"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "userLabelType": "AutomatedLabel"
+ }
+ },
+ "has_graduated_access": true,
+ "is_blue_verified": false,
+ "profile_image_shape": "Circle",
+ "legacy": {
+ "following": true,
+ "can_dm": false,
+ "can_media_tag": true,
+ "created_at": "Tue Aug 21 17:03:01 +0000 2012",
+ "default_profile": true,
+ "default_profile_image": true,
+ "description": "最新の開発版OpenTweenは https://t.co/a0mUFAT58Y から試せます",
+ "entities": {
+ "description": {
+ "urls": [
+ {
+ "display_url": "ci.appveyor.com/project/upsilo…",
+ "expanded_url": "https://ci.appveyor.com/project/upsilon/opentween/build/artifacts?branch=master",
+ "url": "https://t.co/a0mUFAT58Y",
+ "indices": [
+ 17,
+ 40
+ ]
+ }
+ ]
+ }
+ },
+ "fast_followers_count": 0,
+ "favourites_count": 0,
+ "followers_count": 40,
+ "friends_count": 0,
+ "has_custom_timelines": false,
+ "is_translator": false,
+ "listed_count": 0,
+ "location": "",
+ "media_count": 0,
+ "name": "OpenTween 新着コミット",
+ "normal_followers_count": 40,
+ "pinned_tweet_ids_str": [],
+ "possibly_sensitive": false,
+ "profile_image_url_https": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png",
+ "profile_interstitial_type": "",
+ "screen_name": "OpenTweenCommit",
+ "statuses_count": 1991,
+ "translator_type": "none",
+ "verified": false,
+ "want_retweets": true,
+ "withheld_in_countries": []
+ }
+ }
+ }
+ },
+ "card": {
+ "rest_id": "https://t.co/TBwXmNGlWs",
+ "legacy": {
+ "binding_values": [
+ {
+ "key": "photo_image_full_size_large",
+ "value": {
+ "image_value": {
+ "height": 419,
+ "width": 800,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=800x419"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "thumbnail_image",
+ "value": {
+ "image_value": {
+ "height": 200,
+ "width": 400,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=400x400"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "domain",
+ "value": {
+ "string_value": "github.com",
+ "type": "STRING"
+ }
+ },
+ {
+ "key": "thumbnail_image_large",
+ "value": {
+ "image_value": {
+ "height": 300,
+ "width": 600,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=600x600"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "summary_photo_image_small",
+ "value": {
+ "image_value": {
+ "height": 202,
+ "width": 386,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=386x202"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "thumbnail_image_original",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=orig"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "site",
+ "value": {
+ "scribe_key": "publisher_id",
+ "type": "USER",
+ "user_value": {
+ "id_str": "13334762",
+ "path": []
+ }
+ }
+ },
+ {
+ "key": "photo_image_full_size_small",
+ "value": {
+ "image_value": {
+ "height": 202,
+ "width": 386,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=386x202"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "summary_photo_image_large",
+ "value": {
+ "image_value": {
+ "height": 419,
+ "width": 800,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=800x419"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "thumbnail_image_small",
+ "value": {
+ "image_value": {
+ "height": 72,
+ "width": 144,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=144x144"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "thumbnail_image_x_large",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=png&name=2048x2048_2_exp"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "photo_image_full_size_original",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=orig"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "vanity_url",
+ "value": {
+ "scribe_key": "vanity_url",
+ "string_value": "github.com",
+ "type": "STRING"
+ }
+ },
+ {
+ "key": "photo_image_full_size",
+ "value": {
+ "image_value": {
+ "height": 314,
+ "width": 600,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=600x314"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "thumbnail_image_color",
+ "value": {
+ "image_color_value": {
+ "palette": [
+ {
+ "rgb": {
+ "blue": 255,
+ "green": 255,
+ "red": 255
+ },
+ "percentage": 92.66
+ },
+ {
+ "rgb": {
+ "blue": 1,
+ "green": 135,
+ "red": 23
+ },
+ "percentage": 3.14
+ },
+ {
+ "rgb": {
+ "blue": 120,
+ "green": 115,
+ "red": 111
+ },
+ "percentage": 2.31
+ },
+ {
+ "rgb": {
+ "blue": 105,
+ "green": 184,
+ "red": 118
+ },
+ "percentage": 1.7
+ }
+ ]
+ },
+ "type": "IMAGE_COLOR"
+ }
+ },
+ {
+ "key": "title",
+ "value": {
+ "string_value": "バージョン v3.4.1-dev 開発開始 · opentween/OpenTween@41ae9b4",
+ "type": "STRING"
+ }
+ },
+ {
+ "key": "summary_photo_image_color",
+ "value": {
+ "image_color_value": {
+ "palette": [
+ {
+ "rgb": {
+ "blue": 255,
+ "green": 255,
+ "red": 255
+ },
+ "percentage": 92.66
+ },
+ {
+ "rgb": {
+ "blue": 1,
+ "green": 135,
+ "red": 23
+ },
+ "percentage": 3.14
+ },
+ {
+ "rgb": {
+ "blue": 120,
+ "green": 115,
+ "red": 111
+ },
+ "percentage": 2.31
+ },
+ {
+ "rgb": {
+ "blue": 105,
+ "green": 184,
+ "red": 118
+ },
+ "percentage": 1.7
+ }
+ ]
+ },
+ "type": "IMAGE_COLOR"
+ }
+ },
+ {
+ "key": "summary_photo_image_x_large",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=png&name=2048x2048_2_exp"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "summary_photo_image",
+ "value": {
+ "image_value": {
+ "height": 314,
+ "width": 600,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=600x314"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "photo_image_full_size_color",
+ "value": {
+ "image_color_value": {
+ "palette": [
+ {
+ "rgb": {
+ "blue": 255,
+ "green": 255,
+ "red": 255
+ },
+ "percentage": 92.66
+ },
+ {
+ "rgb": {
+ "blue": 1,
+ "green": 135,
+ "red": 23
+ },
+ "percentage": 3.14
+ },
+ {
+ "rgb": {
+ "blue": 120,
+ "green": 115,
+ "red": 111
+ },
+ "percentage": 2.31
+ },
+ {
+ "rgb": {
+ "blue": 105,
+ "green": 184,
+ "red": 118
+ },
+ "percentage": 1.7
+ }
+ ]
+ },
+ "type": "IMAGE_COLOR"
+ }
+ },
+ {
+ "key": "photo_image_full_size_x_large",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=png&name=2048x2048_2_exp"
+ },
+ "type": "IMAGE"
+ }
+ },
+ {
+ "key": "card_url",
+ "value": {
+ "scribe_key": "card_url",
+ "string_value": "https://t.co/TBwXmNGlWs",
+ "type": "STRING"
+ }
+ },
+ {
+ "key": "summary_photo_image_original",
+ "value": {
+ "image_value": {
+ "height": 600,
+ "width": 1200,
+ "url": "https://pbs.twimg.com/card_img/1747139186337964032/Uh3I5jzF?format=jpg&name=orig"
+ },
+ "type": "IMAGE"
+ }
+ }
+ ],
+ "card_platform": {
+ "platform": {
+ "audience": {
+ "name": "production"
+ },
+ "device": {
+ "name": "Swift",
+ "version": "12"
+ }
+ }
+ },
+ "name": "summary_large_image",
+ "url": "https://t.co/TBwXmNGlWs",
+ "user_refs_results": [
+ {
+ "result": {
+ "__typename": "User",
+ "id": "VXNlcjoxMzMzNDc2Mg==",
+ "rest_id": "13334762",
+ "affiliates_highlighted_label": {},
+ "has_graduated_access": true,
+ "is_blue_verified": true,
+ "profile_image_shape": "Square",
+ "legacy": {
+ "can_dm": false,
+ "can_media_tag": true,
+ "created_at": "Mon Feb 11 04:41:50 +0000 2008",
+ "default_profile": false,
+ "default_profile_image": false,
+ "description": "The AI-powered developer platform to build, scale, and deliver secure software.",
+ "entities": {
+ "description": {
+ "urls": []
+ },
+ "url": {
+ "urls": [
+ {
+ "display_url": "github.com",
+ "expanded_url": "http://github.com",
+ "url": "https://t.co/bbJgfyzcJR",
+ "indices": [
+ 0,
+ 23
+ ]
+ }
+ ]
+ }
+ },
+ "fast_followers_count": 0,
+ "favourites_count": 8192,
+ "followers_count": 2550121,
+ "friends_count": 336,
+ "has_custom_timelines": true,
+ "is_translator": false,
+ "listed_count": 18221,
+ "location": "San Francisco, CA",
+ "media_count": 2228,
+ "name": "GitHub",
+ "normal_followers_count": 2550121,
+ "pinned_tweet_ids_str": [],
+ "possibly_sensitive": false,
+ "profile_banner_url": "https://pbs.twimg.com/profile_banners/13334762/1692114901",
+ "profile_image_url_https": "https://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png",
+ "profile_interstitial_type": "",
+ "screen_name": "github",
+ "statuses_count": 8812,
+ "translator_type": "none",
+ "url": "https://t.co/bbJgfyzcJR",
+ "verified": false,
+ "verified_type": "Business",
+ "want_retweets": false,
+ "withheld_in_countries": []
+ }
+ }
+ }
+ ]
+ }
+ },
+ "unmention_data": {},
+ "unified_card": {
+ "card_fetch_state": "NoCard"
+ },
+ "edit_control": {
+ "edit_tweet_ids": [
+ "1619438689213419520"
+ ],
+ "editable_until_msecs": "1674941045000",
+ "is_edit_eligible": true,
+ "edits_remaining": "5"
+ },
+ "is_translatable": true,
+ "views": {
+ "count": "263",
+ "state": "EnabledWithCount"
+ },
+ "source": "IFTTT",
+ "legacy": {
+ "bookmark_count": 0,
+ "bookmarked": false,
+ "created_at": "Sat Jan 28 20:54:05 +0000 2023",
+ "conversation_id_str": "1619438689213419520",
+ "display_text_range": [
+ 0,
+ 50
+ ],
+ "entities": {
+ "hashtags": [],
+ "symbols": [],
+ "timestamps": [],
+ "urls": [
+ {
+ "display_url": "github.com/opentween/Open…",
+ "expanded_url": "https://github.com/opentween/OpenTween/commit/41ae9b439d3477baf7fb582b12faab9f6979761f",
+ "url": "https://t.co/TBwXmNGlWs",
+ "indices": [
+ 27,
+ 50
+ ]
+ }
+ ],
+ "user_mentions": []
+ },
+ "favorite_count": 1,
+ "favorited": false,
+ "full_text": "バージョン v3.4.1-dev 開発開始\n https://t.co/TBwXmNGlWs",
+ "is_quote_status": false,
+ "lang": "ja",
+ "possibly_sensitive": false,
+ "possibly_sensitive_editable": true,
+ "quote_count": 0,
+ "reply_count": 0,
+ "retweet_count": 1,
+ "retweeted": false,
+ "user_id_str": "771871124",
+ "id_str": "1619438689213419520"
+ }
+ }
+ },
+ "tweetDisplayType": "Tweet"
+ },
+ "clientEventInfo": {
+ "component": "following_in_network",
+ "element": "tweet",
+ "details": {
+ "timelinesDetails": {
+ "injectionType": "FollowingInNetwork",
+ "controllerData": "DAACDAABDAABCgABAQAABEoCAAEKAAIAAAAAAAEBAAoACTxaHRTrD0CCCAALAAAAAQ8ADAMAAAALAQACSgQAAAEAAQEKAA6nR6O27vdKZwoAEPS7WmhJVraiAAAAAA=="
+ }
+ }
+ }
+ }
+ },
+ {
+ "entryId": "cursor-top-1748345505739440129",
+ "sortIndex": "1748345505739440129",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAABCgABGENe-W5AJxEKAAIWeWboXhcQAAgAAwAAAAEAAA",
+ "cursorType": "Top"
+ }
+ },
+ {
+ "entryId": "cursor-bottom-1748345505739440028",
+ "sortIndex": "1748345505739440028",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAABCgABGENe-W4__5oKAAIWK_5v3BcQAAgAAwAAAAIAAA",
+ "cursorType": "Bottom"
+ }
+ }
+ ]
+ }
+ ],
+ "responseObjects": {
+ "feedbackActions": [
+ {
+ "key": "-1938325421",
+ "value": {
+ "feedbackType": "Dismiss",
+ "prompt": "See less often",
+ "feedbackUrl": "/1.1/onboarding/fatigue.json?flow_name=premium-plus-upsell-prompt&fatigue_group_name=PremiumPlusUpsellFatigueGroup&action_name=dismiss&scribe_name=dismiss&display_location=home_latest&served_time_secs=1705673025&injection_type=inline_message",
+ "hasUndoAction": false
+ }
+ },
+ {
+ "key": "575125400",
+ "value": {
+ "feedbackType": "SeeFewer",
+ "prompt": "See less often",
+ "confirmation": "OK. You won’t see these as much.",
+ "encodedFeedbackRequest": "LBUEHBUeOQwAAAA=",
+ "hasUndoAction": true,
+ "icon": "Frown"
+ }
+ }
+ ]
+ },
+ "metadata": {
+ "scribeConfig": {
+ "page": "following"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/OpenTween.Tests/SocialProtocol/AccountCollectionTest.cs b/OpenTween.Tests/SocialProtocol/AccountCollectionTest.cs
new file mode 100644
index 000000000..63edea727
--- /dev/null
+++ b/OpenTween.Tests/SocialProtocol/AccountCollectionTest.cs
@@ -0,0 +1,143 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using OpenTween.SocialProtocol.Twitter;
+using Xunit;
+
+namespace OpenTween.SocialProtocol
+{
+ public class AccountCollectionTest
+ {
+ private readonly Random random = new();
+
+ private UserAccount CreateAccountSetting(string key)
+ {
+ return new()
+ {
+ UniqueKey = new(key),
+ TwitterAuthType = APIAuthType.OAuth1,
+ Token = "aaaaa",
+ TokenSecret = "bbbbb",
+ UserId = this.random.Next(),
+ Username = "tetete",
+ };
+ }
+
+ [Fact]
+ public void LoadFromSettings_Test()
+ {
+ using var accounts = new AccountCollection();
+
+ var settingCommon = new SettingCommon
+ {
+ UserAccounts = new()
+ {
+ this.CreateAccountSetting("00000000-0000-4000-8000-000000000000"),
+ },
+ SelectedAccountKey = new("00000000-0000-4000-8000-000000000000"),
+ };
+ accounts.LoadFromSettings(settingCommon);
+
+ Assert.Single(accounts.Items);
+ Assert.Equal(settingCommon.UserAccounts[0].UserId, accounts.Primary.UserId);
+ }
+
+ [Fact]
+ public void LoadFromSettings_RemoveTest()
+ {
+ using var accounts = new AccountCollection();
+
+ var settingCommon1 = new SettingCommon
+ {
+ UserAccounts = new()
+ {
+ this.CreateAccountSetting("00000000-0000-4000-8000-000000000000"),
+ },
+ SelectedAccountKey = new("00000000-0000-4000-8000-000000000000"),
+ };
+ accounts.LoadFromSettings(settingCommon1);
+
+ var accountItem1 = Assert.Single(accounts.Items);
+ Assert.Equal(settingCommon1.UserAccounts[0].UserId, accounts.Primary.UserId);
+
+ var settingCommon2 = new SettingCommon
+ {
+ UserAccounts = new()
+ {
+ // 00000000-0000-4000-8000-000000000000 は削除
+ },
+ SelectedAccountKey = null,
+ };
+ accounts.LoadFromSettings(settingCommon2);
+
+ // 欠けている ID は削除される
+ Assert.Empty(accounts.Items);
+ Assert.Equal(APIAuthType.None, ((TwitterAccount)accounts.Primary).AuthType);
+ Assert.True(accountItem1.IsDisposed);
+ }
+
+ [Fact]
+ public void LoadFromSettings_ReconfigureTest()
+ {
+ using var accounts = new AccountCollection();
+
+ var settingCommon1 = new SettingCommon
+ {
+ UserAccounts = new()
+ {
+ this.CreateAccountSetting("00000000-0000-4000-8000-000000000000"),
+ },
+ SelectedAccountKey = new("00000000-0000-4000-8000-000000000000"),
+ };
+ accounts.LoadFromSettings(settingCommon1);
+
+ var accountItem1 = Assert.Single(accounts.Items);
+ Assert.Equal(settingCommon1.UserAccounts[0].UserId, accounts.Primary.UserId);
+
+ var settingCommon2 = new SettingCommon
+ {
+ UserAccounts = new()
+ {
+ // 同一の ID だが再認証により UserId が変わっている
+ this.CreateAccountSetting("00000000-0000-4000-8000-000000000000"),
+ },
+ SelectedAccountKey = new("00000000-0000-4000-8000-000000000000"),
+ };
+ accounts.LoadFromSettings(settingCommon2);
+
+ var accountItem2 = Assert.Single(accounts.Items);
+ Assert.Equal(settingCommon2.UserAccounts[0].UserId, accounts.Primary.UserId);
+
+ // 同一の ID は同じインスタンスを使用
+ Assert.Same(accountItem1, accountItem2);
+ Assert.NotEqual(
+ settingCommon1.UserAccounts[0].UserId,
+ accountItem2.UserId
+ );
+ Assert.Equal(
+ settingCommon2.UserAccounts[0].UserId,
+ accountItem2.UserId
+ );
+ Assert.False(accountItem2.IsDisposed);
+ }
+ }
+}
diff --git a/OpenTween.Tests/SocialProtocol/Twitter/TwitterAccountTest.cs b/OpenTween.Tests/SocialProtocol/Twitter/TwitterAccountTest.cs
new file mode 100644
index 000000000..d82079679
--- /dev/null
+++ b/OpenTween.Tests/SocialProtocol/Twitter/TwitterAccountTest.cs
@@ -0,0 +1,85 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using Xunit;
+
+namespace OpenTween.SocialProtocol.Twitter
+{
+ public class TwitterAccountTest
+ {
+ [Fact]
+ public void Initialize_Test()
+ {
+ var accountKey = Guid.NewGuid();
+ using var account = new TwitterAccount(accountKey);
+
+ var accountSettings = new UserAccount
+ {
+ UniqueKey = accountKey,
+ TwitterAuthType = APIAuthType.OAuth1,
+ Token = "aaaaa",
+ TokenSecret = "aaaaa",
+ UserId = 11111L,
+ Username = "tetete",
+ };
+ var settingCommon = new SettingCommon();
+ account.Initialize(accountSettings, settingCommon);
+ Assert.Equal(11111L, account.UserId);
+ Assert.Equal("tetete", account.UserName);
+ Assert.Equal(APIAuthType.OAuth1, account.AuthType);
+ Assert.Same(account.Legacy.Api.Connection, account.Connection);
+ }
+
+ [Fact]
+ public void Initialize_ReconfigureTest()
+ {
+ var accountKey = Guid.NewGuid();
+ using var account = new TwitterAccount(accountKey);
+
+ var accountSettings1 = new UserAccount
+ {
+ UniqueKey = accountKey,
+ TwitterAuthType = APIAuthType.OAuth1,
+ Token = "aaaaa",
+ TokenSecret = "aaaaa",
+ UserId = 11111L,
+ Username = "tetete",
+ };
+ var settingCommon1 = new SettingCommon();
+ account.Initialize(accountSettings1, settingCommon1);
+ Assert.Equal(11111L, account.UserId);
+
+ var accountSettings2 = new UserAccount
+ {
+ UniqueKey = accountKey,
+ TwitterAuthType = APIAuthType.OAuth1,
+ Token = "bbbbb",
+ TokenSecret = "bbbbb",
+ UserId = 22222L,
+ Username = "hoge",
+ };
+ var settingCommon2 = new SettingCommon();
+ account.Initialize(accountSettings2, settingCommon2);
+ Assert.Equal(22222L, account.UserId);
+ }
+ }
+}
diff --git a/OpenTween.Tests/TimelineListViewStateTest.cs b/OpenTween.Tests/TimelineListViewStateTest.cs
index eb4d8f4cd..a49d74cbe 100644
--- a/OpenTween.Tests/TimelineListViewStateTest.cs
+++ b/OpenTween.Tests/TimelineListViewStateTest.cs
@@ -20,10 +20,8 @@
// Boston, MA 02110-1301, USA.
using System;
-using System.Collections.Generic;
using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using System.Windows.Forms;
using OpenTween.Models;
using OpenTween.OpenTweenCustomControl;
using Xunit;
@@ -39,5 +37,363 @@ public void Initialize_Test()
var tab = new PublicSearchTabModel("hoge");
var listViewState = new TimelineListViewState(listView, tab);
}
+
+ private void UsingListView(int count, Action func)
+ {
+ using var listView = new DetailsListView();
+ listView.Columns.Add("col");
+ listView.HeaderStyle = ColumnHeaderStyle.None; // 座標計算の邪魔になるため非表示にする
+
+ listView.RetrieveVirtualItem += (s, e) => e.Item = new($"text {e.ItemIndex}");
+ listView.VirtualMode = true;
+ listView.VirtualListSize = count;
+
+ using var imageList = new ImageList { ImageSize = new(1, 19) };
+ listView.SmallImageList = imageList; // Item の高さは 20px
+ listView.ClientSize = new(100, 100); // 高さは 5 行分
+ listView.CreateControl();
+
+ var tab = new PublicSearchTabModel("hoge");
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+
+ var firstDateTime = new DateTimeUtc(2024, 1, 1, 0, 0, 0);
+ foreach (var i in MyCommon.CountUp(0, count - 1))
+ {
+ var post = new PostClass
+ {
+ StatusId = new TwitterStatusId(i.ToString()),
+ CreatedAtForSorting = firstDateTime + TimeSpan.FromSeconds(i),
+ };
+ tab.AddPostQueue(post);
+ }
+ tab.AddSubmit();
+
+ func(listView, tab);
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdAsc_Test()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.EnsureVisible(0); // 一番上にスクロール
+
+ // 投稿日時の昇順に並んでいる場合、新着投稿によってスクロール位置がズレることがないため None を返す
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.None,
+ listViewState.GetScrollLockMode(lockScroll: false)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdAsc_BottomTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.EnsureVisible(9); // 一番下までスクロール
+
+ // 最終行が表示されている場合はスクロール位置を一番下に固定する(新着投稿を表示し続ける)
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToBottom,
+ listViewState.GetScrollLockMode(lockScroll: false)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdAsc_BottomLockScrollTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.EnsureVisible(9); // 一番下までスクロール
+
+ // 最終行が表示されていても lockScroll が true の場合は None を返す(新着投稿にスクロールしない)
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.None,
+ listViewState.GetScrollLockMode(lockScroll: true)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdDesc_TopTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Descending);
+ listView.EnsureVisible(0); // 一番上にスクロール
+
+ // 投稿日時の降順に並んでいて 1 行目が表示されている場合はスクロール位置を一番上に固定する(新着投稿を表示し続ける)
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToTop,
+ listViewState.GetScrollLockMode(lockScroll: false)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdDesc_TopLockScrollTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Descending);
+ listView.EnsureVisible(0); // 一番上にスクロール
+
+ // 先頭行が表示されていても lockScroll が true の場合は FixedToItem を返す(新着投稿にスクロールしない)
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToItem,
+ listViewState.GetScrollLockMode(lockScroll: true)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_IdDesc_BottomTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Descending);
+ listView.EnsureVisible(9); // 一番下にスクロール
+
+ // 先頭行が表示されていない場合は FixedToItem を返す
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToItem,
+ listViewState.GetScrollLockMode(lockScroll: false)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetScrollLockMode_TextAsc_Test()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Data, SortOrder.Ascending);
+ listView.EnsureVisible(0); // 一番上にスクロール
+
+ // ComparerMode.Id 以外の場合は常に FixedToItem を返す
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToItem,
+ listViewState.GetScrollLockMode(lockScroll: false)
+ );
+ });
+ }
+
+ [WinFormsFact]
+ public void GetListViewScroll_Test()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Descending); // 投稿日時の降順
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = listViewState.GetListViewScroll(lockScroll: false);
+ Assert.Equal(
+ TimelineListViewState.ScrollLockMode.FixedToItem,
+ scrollState.ScrollLockMode
+ );
+ Assert.Equal(new TwitterStatusId("7"), scrollState.TopItemStatusId); // 3 行目の StatusId は "7"
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewScroll_FixedToTopTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = new TimelineListViewState.ListViewScroll(
+ ScrollLockMode: TimelineListViewState.ScrollLockMode.FixedToTop,
+ TopItemStatusId: null
+ );
+ listViewState.RestoreListViewScroll(scrollState, forceScroll: false);
+
+ // 一番上にスクロールされた状態になる
+ Assert.Equal(0, listView.TopItem.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewScroll_FixedToBottomTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = new TimelineListViewState.ListViewScroll(
+ ScrollLockMode: TimelineListViewState.ScrollLockMode.FixedToBottom,
+ TopItemStatusId: null
+ );
+ listViewState.RestoreListViewScroll(scrollState, forceScroll: false);
+
+ // 一番下にスクロールされた状態になる(一番下に余白が生じるため null になる)
+ Assert.Null(listView.GetItemAt(0, 82)?.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewScroll_FixedToItemTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = new TimelineListViewState.ListViewScroll(
+ ScrollLockMode: TimelineListViewState.ScrollLockMode.FixedToItem,
+ TopItemStatusId: new TwitterStatusId("5")
+ );
+ listViewState.RestoreListViewScroll(scrollState, forceScroll: false);
+
+ // 6 行目が一番上になる位置にスクロールされた状態になる
+ Assert.Equal(5, listView.TopItem.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewScroll_NoneTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = new TimelineListViewState.ListViewScroll(
+ ScrollLockMode: TimelineListViewState.ScrollLockMode.None,
+ TopItemStatusId: new TwitterStatusId("5")
+ );
+ listViewState.RestoreListViewScroll(scrollState, forceScroll: false);
+
+ // ScrollLockMode.None の場合は何もしない
+ Assert.Equal(2, listView.TopItem.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewScroll_ForceScrollTest()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending);
+ listView.TopItem = listView.Items[2]; // 3 行目が一番上になる位置にスクロールされた状態
+
+ var scrollState = new TimelineListViewState.ListViewScroll(
+ ScrollLockMode: TimelineListViewState.ScrollLockMode.None,
+ TopItemStatusId: new TwitterStatusId("5")
+ );
+ listViewState.RestoreListViewScroll(scrollState, forceScroll: true);
+
+ // ScrollLockMode.None でも forceScroll が true の場合は FixedToItem 相当の動作になる
+ Assert.Equal(5, listView.TopItem.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void GetListViewSelection_EmptyTest()
+ {
+ this.UsingListView(count: 0, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ var selectionState = listViewState.GetListViewSelection();
+ Assert.Empty(selectionState.SelectedStatusIds);
+ Assert.Null(selectionState.SelectionMarkStatusId);
+ Assert.Null(selectionState.FocusedStatusId);
+ });
+ }
+
+ [WinFormsFact]
+ public void GetListViewSelection_Test()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ listView.SelectedIndices.Add(0);
+ listView.SelectedIndices.Add(2);
+ listView.SelectedIndices.Add(3);
+ tab.SelectPosts(new[] { 0, 2, 3 });
+ listView.SelectionMark = 1;
+ listView.FocusedItem = listView.Items[3];
+
+ var selectionState = listViewState.GetListViewSelection();
+ Assert.Equal(new TwitterStatusId[] { new("0"), new("2"), new("3") }, selectionState.SelectedStatusIds);
+ Assert.Equal(new TwitterStatusId("1"), selectionState.SelectionMarkStatusId);
+ Assert.Equal(new TwitterStatusId("3"), selectionState.FocusedStatusId);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewSelection_Test()
+ {
+ this.UsingListView(count: 10, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ var selectionState = new TimelineListViewState.ListViewSelection(
+ SelectedStatusIds: new TwitterStatusId[] { new("1"), new("2"), new("3") },
+ SelectionMarkStatusId: new TwitterStatusId("1"),
+ FocusedStatusId: new TwitterStatusId("3")
+ );
+ listViewState.RestoreListViewSelection(selectionState);
+
+ Assert.Equal(new[] { 1, 2, 3 }, listView.SelectedIndices.Cast());
+ Assert.Equal(1, listView.SelectionMark);
+ Assert.Equal(3, listView.FocusedItem?.Index);
+ });
+ }
+
+ [WinFormsFact]
+ public void RestoreListViewSelection_EmptyTest()
+ {
+ this.UsingListView(count: 0, (listView, tab) =>
+ {
+ var listViewState = new TimelineListViewState(listView, tab);
+
+ var selectionState = new TimelineListViewState.ListViewSelection(
+ SelectedStatusIds: Array.Empty(),
+ SelectionMarkStatusId: null,
+ FocusedStatusId: null
+ );
+ listViewState.RestoreListViewSelection(selectionState);
+
+ Assert.Empty(listView.SelectedIndices);
+ Assert.Equal(-1, listView.SelectionMark);
+ Assert.Null(listView.FocusedItem?.Index);
+ });
+ }
}
}
diff --git a/OpenTween.Tests/TweenMainTest.cs b/OpenTween.Tests/TweenMainTest.cs
index 8beba3b2c..573670771 100644
--- a/OpenTween.Tests/TweenMainTest.cs
+++ b/OpenTween.Tests/TweenMainTest.cs
@@ -23,14 +23,15 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;
-using OpenTween.Api;
using OpenTween.Api.DataModel;
-using OpenTween.Connection;
using OpenTween.Models;
+using OpenTween.OpenTweenCustomControl;
using OpenTween.Setting;
+using OpenTween.SocialProtocol;
using OpenTween.Thumbnail;
using Xunit;
using Xunit.Extensions;
@@ -40,21 +41,26 @@ namespace OpenTween
public class TweenMainTest
{
private record TestContext(
- SettingManager Settings
+ SettingManager Settings,
+ TabInformations TabInfo
);
private void UsingTweenMain(Action func)
{
var settings = new SettingManager("");
var tabinfo = new TabInformations();
- using var twitterApi = new TwitterApi();
- using var twitter = new Twitter(twitterApi);
+ using var accounts = new AccountCollection();
using var imageCache = new ImageCache();
using var iconAssets = new IconAssetsManager();
var thumbnailGenerator = new ThumbnailGenerator(new(autoupdate: false));
- using var tweenMain = new TweenMain(settings, tabinfo, twitter, imageCache, iconAssets, thumbnailGenerator);
- var context = new TestContext(settings);
+ // TabInformation.GetInstance() で取得できるようにする
+ var field = typeof(TabInformations).GetField("Instance",
+ BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.SetField);
+ field.SetValue(null, tabinfo);
+
+ using var tweenMain = new TweenMain(settings, tabinfo, accounts, imageCache, iconAssets, thumbnailGenerator);
+ var context = new TestContext(settings, tabinfo);
func(tweenMain, context);
}
@@ -63,6 +69,176 @@ private void UsingTweenMain(Action func)
public void Initialize_Test()
=> this.UsingTweenMain((_, _) => { });
+ [WinFormsFact]
+ public void AddNewTab_FilterTabTest()
+ {
+ this.UsingTweenMain((tweenMain, context) =>
+ {
+ Assert.Equal(4, tweenMain.ListTab.TabPages.Count);
+
+ var tab = new FilterTabModel("hoge");
+ context.TabInfo.AddTab(tab);
+ tweenMain.AddNewTab(tab, startup: false);
+
+ Assert.Equal(5, tweenMain.ListTab.TabPages.Count);
+
+ var tabPage = tweenMain.ListTab.TabPages[4];
+ Assert.Equal("hoge", tabPage.Text);
+ Assert.Single(tabPage.Controls);
+ Assert.IsType(tabPage.Controls[0]);
+ });
+ }
+
+ [WinFormsFact]
+ public void AddNewTab_UserTimelineTabTest()
+ {
+ this.UsingTweenMain((tweenMain, context) =>
+ {
+ Assert.Equal(4, tweenMain.ListTab.TabPages.Count);
+
+ var tab = new UserTimelineTabModel("hoge", "twitterapi");
+ context.TabInfo.AddTab(tab);
+ tweenMain.AddNewTab(tab, startup: false);
+
+ Assert.Equal(5, tweenMain.ListTab.TabPages.Count);
+
+ var tabPage = tweenMain.ListTab.TabPages[4];
+ Assert.Equal("hoge", tabPage.Text);
+ Assert.Equal(2, tabPage.Controls.Count);
+ Assert.IsType(tabPage.Controls[0]);
+
+ var label = Assert.IsType