fix(web): выбор спринтов при недопустимых id и восстановление испорченного хранилища (#112)

parseSprintIds: при отсутствии допустимых id — все активные (T2.1); токены
только десятичные (T2.3). saveView перезаписывает испорченный JSON (T2.2).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 20:23:42 +04:00
parent a29df27da9
commit 7fbc6bd574
4 changed files with 26 additions and 6 deletions

View File

@ -37,6 +37,12 @@ describe("agilePref", () => {
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);
});
it("испорченный JSON: saveView перезаписывает мусор, readView возвращает сохранённое", () => {
localStorage.setItem(AGILE_KEY, "oops");
saveView("AWPCA", "backlog", view({ hideDone: true, epic: "AURORA-10" }));
expect(readView("AWPCA", "backlog")).toEqual(view({ hideDone: true, epic: "AURORA-10" }));
});
it("мусор: «[1]» → DEFAULT_VIEW", () => {
localStorage.setItem(AGILE_KEY, "[1]");
expect(readView("AWPCA", "backlog")).toEqual(DEFAULT_VIEW);

View File

@ -41,7 +41,12 @@ export function readView(label: string, page: AgilePage): AgileView {
export function saveView(label: string, page: AgilePage, v: AgileView): void {
try {
const root = readRoot();
let root: Obj;
try {
root = readRoot();
} catch {
root = {}; // испорченный JSON перезаписывается, иначе вид не сохранится никогда
}
const project = isObj(root[label]) ? root[label] : {};
const view: AgileView = {
assignee: v.assignee, epic: v.epic, hideDone: v.hideDone,

View File

@ -202,7 +202,15 @@ describe("parseSprintIds", () => {
];
it("id из строки, только среди selectableSprints", () => {
expect(parseSprintIds("102,999", sprints)).toEqual([102]);
expect(parseSprintIds("101", sprints)).toEqual([]);
expect(parseSprintIds("999,103", sprints)).toEqual([103]);
});
it("если не осталось ни одного допустимого id — все активные", () => {
expect(parseSprintIds("999", sprints)).toEqual([102]);
expect(parseSprintIds("101", sprints)).toEqual([102]);
});
it("токены — только десятичные цифры (1e2, 0x10, 1.0 отбрасываются)", () => {
expect(parseSprintIds("1e2,0x66,102.0", sprints)).toEqual([102]);
expect(parseSprintIds("1e2,103", sprints)).toEqual([103]);
});
it("порядок — как в selectableSprints, не как в строке", () => {
expect(parseSprintIds("103,102", sprints)).toEqual([102, 103]);

View File

@ -77,14 +77,15 @@ export function selectableSprints(sprints: AgileSprint[]): AgileSprint[] {
return [...active, ...future.sort((a, b) => startMs(a) - startMs(b))];
}
// id из «101,102» среди selectableSprints, в их порядке; пусто или без годных чисел — все активные
// id из «101,102» среди selectableSprints, в их порядке; недопустимые отбрасываются; если не осталось ни одного — все активные
export function parseSprintIds(param: string | null, sprints: AgileSprint[]): number[] {
const selectable = selectableSprints(sprints);
const ids = new Set(
(param ?? "").split(",").map((x) => x.trim()).filter((x) => x !== "").map(Number).filter((n) => Number.isInteger(n)),
(param ?? "").split(",").map((x) => x.trim()).filter((x) => /^\d+$/.test(x)).map(Number),
);
if (ids.size === 0) return selectable.filter((s) => s.state === "active").map((s) => s.id);
return selectable.filter((s) => ids.has(s.id)).map((s) => s.id);
const picked = selectable.filter((s) => ids.has(s.id)).map((s) => s.id);
if (picked.length > 0) return picked;
return selectable.filter((s) => s.state === "active").map((s) => s.id);
}
// строки-эпики из всех спринтов и бэклога (по rank, без повторов), затем ключи из epic_key без строки (по ключу);