Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions entrypoints/background/grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createSerialQueue } from "../../utils/grouping/serial";
import type { Rule } from "../../utils/rules/types";
import { stub, useFakeTimersInTest } from "../../utils/testing/mocks";
import { onRulesChanged, onTabRemoved, onTitleChanged, regroupTab } from "./grouping";
import { titlesStore } from "./rules";

const DEV: Rule = { id: "dev", name: "開発", color: "blue", conditions: [{ type: "contains", value: "/dev/" }] };

Expand Down Expand Up @@ -51,6 +52,29 @@ function mockDevTabWindow() {
return { group, updateGroup };
}

/** ウィンドウ1にタブ10(/dev/を含むURL)があり、すでにグループ100(タイトル「開発」)に入っている状態にする */
function mockDevTabInGroup() {
stub(
fakeBrowser.tabs,
"query",
vi
.fn<(queryInfo: Browser.tabs.QueryInfo) => Promise<Browser.tabs.Tab[]>>()
.mockResolvedValue([fakeTab({ id: 10, url: "https://example.com/dev/1", groupId: 100, windowId: 1 })]),
);
stub(
fakeBrowser.tabGroups,
"query",
vi
.fn<(queryInfo: Browser.tabGroups.QueryInfo) => Promise<Browser.tabGroups.TabGroup[]>>()
.mockResolvedValue([{ id: 100, windowId: 1, title: "開発", color: "blue", collapsed: false, shared: false }]),
);
const group = vi.fn<(options: Browser.tabs.GroupOptions) => Promise<number>>();
stub(fakeBrowser.tabs, "group", group);
const updateGroup = vi.fn<(groupId: number, properties: Browser.tabGroups.UpdateProperties) => Promise<undefined>>();
stub(fakeBrowser.tabGroups, "update", updateGroup);
return { group, updateGroup };
}

/** rulesReader.read() が読む保存されたルールの一覧を設定する(本番の storage への保存に相当) */
async function setStoredRules(rules: readonly Rule[]): Promise<void> {
await fakeBrowser.storage.local.set({ rules: [...rules] });
Expand Down Expand Up @@ -203,3 +227,32 @@ describe("名前が衝突するルールの編集とタブイベントの重な
expect(group).toHaveBeenCalledWith({ createProperties: { windowId: 1 }, tabIds: [10] });
});
});

describe("ルール名の打ち直し中のタブイベントとの競合(Issue #110)", () => {
it("反映前のタブイベントが記録を先に進めても、確定後に実際のグループを改名する", async () => {
// 前提: ルール「開発」はすでにグループ100(タイトル「開発」)を持っており、titlesStore にも記録済み。
// このルールを「Dev」に改名する。ストレージの書き込み自体はすぐ終わるが、onRulesChanged
// (storage の watch 通知)が届くのは、別経路のタブイベントより後になることがある
// 検証: 反映タスクがキューへ積まれる前に、そのタブイベントが先に新しい名前「Dev」を読み、
// titlesStore の記録だけを先に「Dev」へ進めてしまっても、通知が届いた後の反映は
// 実際のグループ100を「開発」から「Dev」へ改名する(先回りした記録のせいでスキップされない)
useFakeTimersInTest();
fakeBrowser.reset();
const { updateGroup } = mockDevTabInGroup();
await setStoredRules([DEV]);
await titlesStore.write(new Map([["dev", "開発"]]));
const { onRulesChanged: onChange, onTabEvent, flush } = createHarness();

const renamed = { ...DEV, name: "Dev" };
await setStoredRules([renamed]);
await onTabEvent(1, 10);
expect(updateGroup).not.toHaveBeenCalled();

// ここでようやく storage の watch 通知が届き、反映タスクがキューへ積まれる
onChange([renamed], [DEV]);
await vi.advanceTimersByTimeAsync(300);
await flush();

expect(updateGroup).toHaveBeenCalledWith(100, { title: "Dev", color: "blue" });
});
});
10 changes: 6 additions & 4 deletions entrypoints/background/grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Browser } from "wxt/browser";
import { debounceChanges } from "../../utils/grouping/debounce";
import type { RulesState } from "../../utils/grouping/regroup";
import { applyRuleChange, regroupTabs } from "../../utils/grouping/regroup";
import { currentTitles } from "../../utils/grouping/rule-change";
import { titlesForNewRules } from "../../utils/grouping/rule-change";
import type { createSerialQueue } from "../../utils/grouping/serial";
import { logListenerErrors } from "../../utils/logging/listener";
import { getAppLogger } from "../../utils/logging/setup";
Expand Down Expand Up @@ -59,15 +59,17 @@ async function regroupAll(): Promise<void> {
* @param windowId - 対象のタブが属するウィンドウの ID
* @param tabId - 判定し直す対象のタブの ID
* @returns enqueue に積む処理
* @remarks 判定に使ったルールの今の有効なタイトルを titlesStore にも記録する。これをしないと、このタブイベントが
* @remarks 判定に使ったルールのうち、まだ記録に無いものだけタイトルを titlesStore に足す。これをしないと、このタブイベントが
* ルールの変更の反映より先にグループを作った場合、そのグループの存在が diffRules の記録から漏れ、
* 後から確定する名前・色の変更がそのグループへ届かなくなる(Issue #36)
* 後から確定する名前・色の変更がそのグループへ届かなくなる(Issue #36)。
* 記録済みのルールのタイトルは上書きしない。ここで名前の変更まで記録してしまうと、まだ実行していないルール変更の
* 反映(applyRuleChange)が「変更済み」と誤認し、実際のグループの改名を永久にスキップしてしまう(Issue #110)
*/
export function regroupTab(windowId: number, tabId: number): () => Promise<void> {
return async () => {
const state = await readRulesState();
await regroupTabs(browserTabs, state, { windowId, tabIds: [tabId] });
await titlesStore.write(currentTitles(state.rules, state.titles));
await titlesStore.write(titlesForNewRules(state.rules, state.titles));
};
}

Expand Down
25 changes: 17 additions & 8 deletions utils/grouping/rule-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,27 @@ export function diffRules(
}

/**
* ルールの一覧自体は変わっていない読み直し(タブイベントの判定等)で、記録されているタイトルを
* 今の有効性に合わせ直す。
* ルールの一覧自体は変わっていない読み直し(タブイベントの判定等)で、まだ記録に無いルールの分だけ
* タイトルを足す。
*
* @param rules - 判定に使うルールの一覧
* @param titles - 現在記録されているタイトル
* @returns 有効なルールが今持つべきタイトル
* @remarks diffRules を oldRules と newRules が同じ呼び出しとして使うため、名前・色の変更(updates)は出ない。
* タブイベントの素朴な判定(regroupTab)はこのタイトルの記録を更新しないため、後から来るルール変更の反映
* (diffRules)が、タブイベントで先に作られたグループの存在を見落とさないよう、ここで記録を追いつかせる
* @returns 記録に無かった分だけタイトルを足した記録
* @remarks 足すのは、タブイベントがルール変更の反映より先にグループを新規作成した場合に、そのグループの存在が
* diffRules の記録から漏れ、後から来るルール変更の反映がそのグループへ届かなくなるのを防ぐため(Issue #36)。
* 記録済みのルールのタイトルは、ルールの今の名前と違っていても上書きしない。タブイベントの素朴な判定
* (regroupTab)は実際の改名を行わないため、ここで記録だけ新しい名前に進めてしまうと、まだ実行していない
* ルール変更の反映(diffRules・applyRuleChange)が「すでに変更済み」と誤認し、実際のグループの改名を
* 永久にスキップしてしまう(Issue #110)
*/
export function currentTitles(rules: readonly Rule[], titles: RuleTitles): RuleTitles {
return diffRules(rules, rules, titles).titles;
export function titlesForNewRules(rules: readonly Rule[], titles: RuleTitles): RuleTitles {
const result = new Map(titles);
for (const rule of validRules(rules, titles)) {
if (!result.has(rule.id)) {
result.set(rule.id, rule.name);
}
}
return result;
}

/** 有効なルールのグループのタイトル・色の変更。持っていたタイトルの変更を先に決め、変更されないタイトルを引き継いだルールは色だけを変える */
Expand Down
41 changes: 41 additions & 0 deletions utils/grouping/titles-for-new-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";

import type { Rule } from "../rules/types";
import { titlesForNewRules } from "./rule-change";

const DEV: Rule = { id: "dev", name: "開発", color: "blue", conditions: [{ type: "contains", value: "github.com" }] };
const DOCS: Rule = {
id: "docs",
name: "資料",
color: "green",
conditions: [{ type: "contains", value: "example.com" }],
};

/** id のルールが name のタイトルを持っている RuleTitles */
function titlesOf(...entries: [Rule, string][]): Map<string, string> {
return new Map(entries.map(([rule, title]) => [rule.id, title]));
}

describe("タブイベントの判定し直しで足すタイトル(Issue #36・#110)", () => {
it("記録に無いルールのタイトルを足す", () => {
// 前提: DEV のタイトルがまだ記録に無い(タブイベントがルールの新規作成より先に判定した場合に相当)
// 検証: DEV のタイトルとして、今の名前「開発」が足される
expect(titlesForNewRules([DEV], new Map())).toStrictEqual(titlesOf([DEV, "開発"]));
});

it("記録済みのルールのタイトルは、今の名前と違っていても上書きしない", () => {
// 前提: DEV は記録上まだ「開発」のままだが、ルールの名前はすでに「Dev」に変わっている
// (ルール変更の反映がまだ終わっていない間に、タブイベントが判定し直した場合に相当)
// 検証: 記録は「開発」のまま変わらない。ここで「Dev」に進めてしまうと、まだ実行していないルール変更の
// 反映が「変更済み」と誤認し、実際のグループの改名が永久にスキップされてしまう(Issue #110)
expect(titlesForNewRules([{ ...DEV, name: "Dev" }], titlesOf([DEV, "開発"]))).toStrictEqual(
titlesOf([DEV, "開発"]),
);
});

it("記録済みのルールと記録に無いルールが混ざっていても、記録済みの分だけ変えない", () => {
expect(titlesForNewRules([{ ...DEV, name: "Dev" }, DOCS], titlesOf([DEV, "開発"]))).toStrictEqual(
titlesOf([DEV, "開発"], [DOCS, "資料"]),
);
});
});
Loading