mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(folio-web): predicates for counting commits against submit actions
This commit is contained in:
1 parent
2aba29a6e3
commit
dc2da35e2c
2 files changed
+511
No files matched your search
@@ -0,0 +1,148 @@
|
||||
// A Home reading and the carrier it advances.
|
||||
//
|
||||
// A frame that is not Home, and a Home frame whose card list has not rendered,
|
||||
// are the same case: nothing was read, so the last value we did read is
|
||||
// reported and carried on unchanged. An empty list is equally "the app has no
|
||||
// accounts" and "the app has not drawn them yet", and taking it as a reading is
|
||||
// what would bank an empty window and leave the next rise arriving with no
|
||||
// budget to cover it.
|
||||
//
|
||||
// `fresh` marks the one event that closes a comparison window, so it also
|
||||
// resets the submit count taken over that window.
|
||||
export interface HomeReading<T> {
|
||||
value: T | null;
|
||||
carrier: T | null;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
export function readHomeCards<T>(args: {
|
||||
onHome: boolean;
|
||||
reading: T | null;
|
||||
previousCarrier: T | null;
|
||||
}): HomeReading<T> {
|
||||
const { onHome, reading, previousCarrier } = args;
|
||||
if (!onHome || reading === null) {
|
||||
return { value: previousCarrier, carrier: previousCarrier, fresh: false };
|
||||
}
|
||||
return { value: reading, carrier: reading, fresh: true };
|
||||
}
|
||||
|
||||
export interface CardReading {
|
||||
accountId: string;
|
||||
count: number | undefined;
|
||||
}
|
||||
|
||||
// Transactions committed per account, keyed by the account id the card carries.
|
||||
// A card whose id or count is unreadable is left out rather than guessed at,
|
||||
// and a reading with nothing left in it is unknown.
|
||||
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, number> | null {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const card of cards) {
|
||||
if (card.accountId === "" || card.count === undefined) continue;
|
||||
if (!Number.isSafeInteger(card.count)) continue;
|
||||
counts[card.accountId] = card.count;
|
||||
}
|
||||
return Object.keys(counts).length === 0 ? null : counts;
|
||||
}
|
||||
|
||||
// state.lastAction as the runner builds it (internal/verifier/marshal.go
|
||||
// lastActionFields), read defensively: every field is what a Go struct decided
|
||||
// to emit, not something this file can trust a compile-time shape for.
|
||||
export interface ObservedAction {
|
||||
kind?: string;
|
||||
on?: string | object;
|
||||
applied?: true | null;
|
||||
}
|
||||
|
||||
// Is this the action that commits a transaction? Nothing else in the app
|
||||
// reaches createTransaction: the add-transaction form's onSubmit is the only
|
||||
// caller, and the txn-submit button is the only control that submits it. A
|
||||
// double tap counts once, because it is one action.
|
||||
export function isTxnSubmitTap(lastAction: ObservedAction | null): boolean {
|
||||
if (lastAction == null) return false;
|
||||
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false;
|
||||
const on = lastAction.on;
|
||||
const onText = typeof on === "string" ? on : on != null ? JSON.stringify(on) : "";
|
||||
return onText.includes("txn-submit");
|
||||
}
|
||||
|
||||
// Could the app have committed anything for that submit? The amount field as
|
||||
// the LANDING frame shows it is the form state the tap read: one action runs
|
||||
// per step and a tap changes no field, so nothing else could have. Off the
|
||||
// transaction screen there is no field to read, and undefined is unknown,
|
||||
// which counts.
|
||||
//
|
||||
// False only where the app's own code must have refused. This mirrors
|
||||
// parseCents (src/format.ts) plus the handler's own `cents <= 0` rejection, and
|
||||
// an empty field never reaches either: the submit button is disabled while the
|
||||
// amount is blank, so no click fires at all.
|
||||
//
|
||||
// This is the difference between a bound and a useless one. The window is an
|
||||
// upper bound on the transactions its interval could hold, and a bound inflated
|
||||
// by taps that commit nothing is a bound a double submit hides behind: of 108
|
||||
// add-transaction frames in the recalibration run, 65 showed the amount field
|
||||
// empty.
|
||||
export function submitCouldCommit(amountText: string | undefined): boolean {
|
||||
if (amountText === undefined) return true;
|
||||
const trimmed = amountText.trim().replace(/,/g, "");
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false;
|
||||
const dot = trimmed.indexOf(".");
|
||||
const whole = dot < 0 ? trimmed : trimmed.slice(0, dot);
|
||||
const fraction = dot < 0 ? "" : trimmed.slice(dot + 1);
|
||||
const cents = Number(whole) * 100 + Number((fraction + "00").slice(0, 2));
|
||||
return Number.isSafeInteger(cents) && cents > 0;
|
||||
}
|
||||
|
||||
// Counts the submit actions inside the window the counting property compares
|
||||
// over: from the last Home reading we took to this step, inclusive of this
|
||||
// step's action.
|
||||
//
|
||||
// Counting ACTIONS rather than transactions is the whole point. A double tap is
|
||||
// one action that commits two transactions, which is precisely the defect, so a
|
||||
// rule phrased in transactions could not tell it apart from two healthy
|
||||
// submits.
|
||||
//
|
||||
// A submit whose dispatch the runner could not confirm counts, because this
|
||||
// number is an upper bound and the tap may well have landed.
|
||||
export function countSubmitsInWindow(args: {
|
||||
previousCount: number;
|
||||
lastAction: ObservedAction | null;
|
||||
amountText: string | undefined;
|
||||
fresh: boolean;
|
||||
}): { reported: number; next: number } {
|
||||
const { previousCount, lastAction, amountText, fresh } = args;
|
||||
const counts = isTxnSubmitTap(lastAction) && submitCouldCommit(amountText);
|
||||
const reported = previousCount + (counts ? 1 : 0);
|
||||
return { reported, next: fresh ? 0 : reported };
|
||||
}
|
||||
|
||||
// Every accepted submit commits exactly one transaction, so over any window the
|
||||
// number of transactions committed cannot exceed the number of submit actions
|
||||
// taken. A double submit is one action committing two, which is the only way to
|
||||
// break it. Refused submits commit nothing and are not counted, so the healthy
|
||||
// case sits at or under the bound rather than at it.
|
||||
//
|
||||
// Only accounts present in BOTH readings are counted, and only upward movement.
|
||||
// A card that could not be read drops out of the sum, which makes the result a
|
||||
// LOWER BOUND on the transactions committed; a lower bound that already exceeds
|
||||
// the submit count is still a real violation. Missing cards can cost a
|
||||
// detection, they cannot manufacture one.
|
||||
//
|
||||
// Transactions are never deleted, so a per-account count only ever rises.
|
||||
export function committedTransactionsExceedSubmits(args: {
|
||||
countsBefore: Record<string, number> | null;
|
||||
countsAfter: Record<string, number> | null;
|
||||
submitsInWindow: number;
|
||||
}): boolean {
|
||||
const { countsBefore, countsAfter, submitsInWindow } = args;
|
||||
if (countsBefore === null || countsAfter === null) return false;
|
||||
if (!Number.isSafeInteger(submitsInWindow)) return false;
|
||||
let committed = 0;
|
||||
for (const accountId of Object.keys(countsAfter)) {
|
||||
const before = countsBefore[accountId];
|
||||
const after = countsAfter[accountId];
|
||||
if (before === undefined || after === undefined) continue;
|
||||
if (after > before) committed += after - before;
|
||||
}
|
||||
return committed > submitsInWindow;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
homeTxnCountsOf,
|
||||
isTxnSubmitTap,
|
||||
readHomeCards,
|
||||
submitCouldCommit,
|
||||
} from "../../../examples/folio-web/sanderling/predicates.ts";
|
||||
import type {
|
||||
CardReading,
|
||||
ObservedAction,
|
||||
} from "../../../examples/folio-web/sanderling/predicates.ts";
|
||||
|
||||
const CHECKING = "acct-checking";
|
||||
const SAVINGS = "acct-savings";
|
||||
|
||||
const submitTap = (): ObservedAction => ({ kind: "Tap", on: "id:txn-submit", applied: true });
|
||||
const submitDoubleTap = (): ObservedAction => ({
|
||||
kind: "DoubleTap",
|
||||
on: "id:txn-submit",
|
||||
applied: true,
|
||||
});
|
||||
const otherTap = (on: string): ObservedAction => ({ kind: "Tap", on, applied: true });
|
||||
|
||||
test("a submit tap and a submit double tap are both one submit action", () => {
|
||||
assert.equal(isTxnSubmitTap(submitTap()), true);
|
||||
assert.equal(isTxnSubmitTap(submitDoubleTap()), true);
|
||||
});
|
||||
|
||||
test("taps on other controls are not submits", () => {
|
||||
assert.equal(isTxnSubmitTap(otherTap("id:add-txn")), false);
|
||||
assert.equal(isTxnSubmitTap(otherTap("id:add-account-submit")), false);
|
||||
assert.equal(isTxnSubmitTap({ kind: "InputText", on: "id:txn-amount" }), false);
|
||||
assert.equal(isTxnSubmitTap(null), false);
|
||||
});
|
||||
|
||||
// The disabled button and parseCents (src/format.ts) between them refuse these,
|
||||
// so they raise no bound. Of 108 add-transaction frames in the recalibration
|
||||
// run, 65 carried an empty amount field.
|
||||
test("amounts the app must have refused do not count as submits", () => {
|
||||
assert.equal(submitCouldCommit(""), false);
|
||||
assert.equal(submitCouldCommit(" "), false);
|
||||
assert.equal(submitCouldCommit("0"), false);
|
||||
assert.equal(submitCouldCommit("0.00"), false);
|
||||
assert.equal(submitCouldCommit("-5"), false);
|
||||
assert.equal(submitCouldCommit("abc"), false);
|
||||
assert.equal(submitCouldCommit("1.234"), false);
|
||||
assert.equal(submitCouldCommit("999999999999999999999"), false);
|
||||
});
|
||||
|
||||
test("amounts the app accepts count as submits", () => {
|
||||
assert.equal(submitCouldCommit("1"), true);
|
||||
assert.equal(submitCouldCommit("12.34"), true);
|
||||
assert.equal(submitCouldCommit("0.01"), true);
|
||||
assert.equal(submitCouldCommit("99999"), true);
|
||||
assert.equal(submitCouldCommit("1,234"), true);
|
||||
});
|
||||
|
||||
// Off the transaction screen there is no field to read, and unknown counts:
|
||||
// the bound has to hold every submit the window could contain.
|
||||
test("an unreadable amount counts, because the tap may have committed", () => {
|
||||
assert.equal(submitCouldCommit(undefined), true);
|
||||
});
|
||||
|
||||
const before = { [CHECKING]: 3, [SAVINGS]: 1 };
|
||||
|
||||
test("healthy window: three submits, three transactions", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { [CHECKING]: 5, [SAVINGS]: 2 },
|
||||
submitsInWindow: 3,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("refused submits commit nothing, which is under the bound", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: before,
|
||||
submitsInWindow: 4,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The defect, stated directly: one action, two rows.
|
||||
test("double submit: one action commits two transactions", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { [CHECKING]: 5, [SAVINGS]: 1 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("boundary: committed equal to the submit count is not a violation", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { [CHECKING]: 4, [SAVINGS]: 1 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { [CHECKING]: 4, [SAVINGS]: 2 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Counting actions against transactions rather than gating on a one-submit
|
||||
// window is what lets a wide window stay evidence.
|
||||
test("wide window: five submits committing six transactions still fires", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { [CHECKING]: 8, [SAVINGS]: 4 },
|
||||
submitsInWindow: 5,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// A card that could not be read drops out of the sum, so the result is a lower
|
||||
// bound on what committed. Losing a card can cost a detection; it must never
|
||||
// manufacture one.
|
||||
test("an account missing from either reading is not counted", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { [CHECKING]: 3, [SAVINGS]: 1 },
|
||||
countsAfter: { [CHECKING]: 3 },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { [CHECKING]: 3 },
|
||||
countsAfter: { [CHECKING]: 3, [SAVINGS]: 9 },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown reading on either side is not evidence", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: null,
|
||||
countsAfter: { [CHECKING]: 99 },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { [CHECKING]: 0 },
|
||||
countsAfter: null,
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a home frame whose cards have not rendered is unknown, not zero accounts", () => {
|
||||
assert.equal(homeTxnCountsOf([]), null);
|
||||
assert.equal(homeTxnCountsOf([{ accountId: CHECKING, count: undefined }]), null);
|
||||
assert.deepEqual(homeTxnCountsOf([{ accountId: CHECKING, count: 0 }]), { [CHECKING]: 0 });
|
||||
});
|
||||
|
||||
test("a card with no readable id is left out rather than guessed at", () => {
|
||||
assert.deepEqual(
|
||||
homeTxnCountsOf([
|
||||
{ accountId: "", count: 4 },
|
||||
{ accountId: SAVINGS, count: 1 },
|
||||
]),
|
||||
{ [SAVINGS]: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
// One step of a run, as the spec sees it: whether this frame is Home, the cards
|
||||
// on it, the action that landed on it, and the amount field it shows.
|
||||
interface Frame {
|
||||
onHome: boolean;
|
||||
cards?: readonly CardReading[];
|
||||
lastAction?: ObservedAction;
|
||||
amountText?: string;
|
||||
}
|
||||
|
||||
// Replays frames through the same three predicates the spec wires together,
|
||||
// returning the verdict of submitCommitsOneTransactionPerAction at each step.
|
||||
function replay(frames: readonly Frame[]): boolean[] {
|
||||
let carrier: Record<string, number> | null = null;
|
||||
let previousCounts: Record<string, number> | null = null;
|
||||
let submits = 0;
|
||||
return frames.map((frame) => {
|
||||
const reading = readHomeCards({
|
||||
onHome: frame.onHome,
|
||||
reading: frame.cards ? homeTxnCountsOf(frame.cards) : null,
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
carrier = reading.carrier;
|
||||
const counted = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: frame.lastAction ?? null,
|
||||
amountText: frame.amountText,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = counted.next;
|
||||
const violated = committedTransactionsExceedSubmits({
|
||||
countsBefore: previousCounts,
|
||||
countsAfter: reading.value,
|
||||
submitsInWindow: counted.reported,
|
||||
});
|
||||
previousCounts = reading.value;
|
||||
return violated;
|
||||
});
|
||||
}
|
||||
|
||||
const home = (count: number): Frame => ({
|
||||
onHome: true,
|
||||
cards: [{ accountId: CHECKING, count }],
|
||||
});
|
||||
|
||||
// The walk the fuzzer takes: Home, into the account, into the form, type, tap
|
||||
// submit, back out, Home again. The two compared readings are eight steps
|
||||
// apart and the property has to stay quiet the whole way.
|
||||
const walkToSubmit: readonly Frame[] = [
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: { kind: "InputText", on: "id:txn-amount" }, amountText: "12.34" },
|
||||
];
|
||||
|
||||
test("one submit committing one transaction is quiet across an eight-step window", () => {
|
||||
const verdicts = replay([
|
||||
...walkToSubmit,
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "12.34" },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
home(4),
|
||||
]);
|
||||
assert.deepEqual(verdicts, [false, false, false, false, false, false, false, false]);
|
||||
});
|
||||
|
||||
// The planted defect: the same walk, one double tap, two rows.
|
||||
test("a double tap committing two transactions fires at the next home reading", () => {
|
||||
const verdicts = replay([
|
||||
...walkToSubmit,
|
||||
{ onHome: false, lastAction: submitDoubleTap(), amountText: "12.34" },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
home(5),
|
||||
]);
|
||||
assert.deepEqual(verdicts, [false, false, false, false, false, false, false, true]);
|
||||
});
|
||||
|
||||
// A window can hold several submits and several visits to the form. Every
|
||||
// transaction is accounted for by an action, so the bound holds.
|
||||
test("many submits across a wide window stay under the bound", () => {
|
||||
const verdicts = replay([
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "1" },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "250" },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "99999" },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
home(6),
|
||||
]);
|
||||
assert.equal(verdicts.some((violated) => violated), false);
|
||||
});
|
||||
|
||||
// The same wide window with one of the three taps doubled. The rise of four
|
||||
// against a budget of three is what convicts.
|
||||
test("a double tap hidden among healthy submits still fires", () => {
|
||||
const verdicts = replay([
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "1" },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitDoubleTap(), amountText: "250" },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "99999" },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
home(7),
|
||||
]);
|
||||
assert.deepEqual(verdicts[9], true);
|
||||
});
|
||||
|
||||
// Submits the app refused raise no bound, which is what keeps the window tight
|
||||
// enough to convict. The same trace with the three empty-field taps counted
|
||||
// would acquit a double submit.
|
||||
test("taps on the disabled submit button do not pad the budget", () => {
|
||||
const padded: readonly Frame[] = [
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("desc:Checking, $0.00") },
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "" },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "" },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "" },
|
||||
{ onHome: false, lastAction: { kind: "InputText", on: "id:txn-amount" }, amountText: "1" },
|
||||
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
|
||||
{ onHome: false, lastAction: otherTap("id:back") },
|
||||
home(5),
|
||||
];
|
||||
assert.deepEqual(replay(padded)[9], true);
|
||||
});
|
||||
|
||||
// A fresh Home reading closes one window and opens the next, so a submit
|
||||
// already accounted for cannot be spent twice.
|
||||
test("the submit budget resets on every home reading", () => {
|
||||
const verdicts = replay([
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitTap(), amountText: "1" },
|
||||
home(4),
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
|
||||
home(6),
|
||||
]);
|
||||
assert.deepEqual(verdicts, [false, false, false, false, false, false, true]);
|
||||
});
|
||||
|
||||
// A submit that lands back on Home belongs to the window that ends there, so
|
||||
// the rise it caused is covered rather than convicted.
|
||||
test("a submit whose landing frame is home is counted in that window", () => {
|
||||
const verdicts = replay([
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: true, cards: [{ accountId: CHECKING, count: 4 }], lastAction: submitTap() },
|
||||
]);
|
||||
assert.deepEqual(verdicts, [false, false, false]);
|
||||
});
|
||||
|
||||
// Home renders its own node before its card list, and an empty list is
|
||||
// UNKNOWN, not "no accounts". Writing it into the carrier is what would leave
|
||||
// the next real reading comparing against nothing.
|
||||
test("a home frame with no cards yet neither convicts nor closes the window", () => {
|
||||
const verdicts = replay([
|
||||
home(3),
|
||||
{ onHome: false, lastAction: otherTap("id:add-txn") },
|
||||
{ onHome: false, lastAction: submitDoubleTap(), amountText: "1" },
|
||||
{ onHome: true, cards: [] },
|
||||
home(5),
|
||||
]);
|
||||
assert.deepEqual(verdicts, [false, false, false, false, true]);
|
||||
});
|
||||
Reference in new issue
Block a user