mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
* refactor: rename examples/sample-app to examples/folio Directory-level rename and path references in Go tests, bundle-check, top-level README, and getting-started docs. Package declarations, Gradle config, iOS bundle IDs, and class names follow in later commits. * refactor(folio): rename Kotlin package dev.uatu.sample to app.folio Moves source dirs and sqldelight schema from dev/uatu/sample to app/folio, updates package declarations and imports, and switches Android namespace/applicationId, iOS binaryOption bundleId, and sqldelight database packageName to the new identifier. * refactor(folio): rename SampleApplication to FolioApplication Android manifest now points at .FolioApplication with label 'Folio' instead of 'Uatu Sample'. * refactor(folio): set iOS bundle id and display name to Folio bundleIdPrefix + PRODUCT_BUNDLE_IDENTIFIER -> app.folio. CFBundleName + CFBundleDisplayName -> 'Folio'. * refactor(folio): update demo email to [email protected] * refactor(folio): point justfile at app.folio bundle id Updates xcrun simctl launch target, uatu test --bundle-id, and the build/uninstall comments to reference folio instead of sample. * test: update fixture package ids to app.folio Sidecar activity-resolver test and verifier spec-integration XML fixtures referenced the old dev.uatu.sample Android package. Updates them to match the folio app's real package id so the tests stay representative of what the CLI sees on-device. * test(verifier): rename SampleApp identifiers to Folio Renames TestSampleAppSpec* functions, bundleSampleAppSpec helper, and sampleAppHierarchyXML const (now loginHierarchyXML for consistency with the other per-screen fixtures). Updates trailing sample-app mentions in comments and assertion messages. * refactor(folio): rename Gradle/npm/wasm project identifiers to folio settings.gradle.kts rootProject.name, package.json + package-lock.json name, and the WasmJS index.html <title> all still read 'uatu-sample' / 'Uatu Sample'. Realigns them with the Folio brand. * docs(folio): rewrite README title + getting-started bundle id examples/folio/README.md is now titled 'Folio' with the Kotlin source paths corrected to app/folio. Getting-started example uses --bundle-id app.folio. Harness launch message is now generic ('app under test') since uatu-sample-harness is not specific to folio. * chore(folio): drop trailing 'sample' reference in gradle.properties * refactor(folio): rename LoginPage composable to LoginScreen Align with KMP/Android industry convention (NowInAndroid, Cash App, JetBrains samples use Screen, not Page). * refactor(folio): rename HomePage composable to HomeScreen * refactor(folio): rename AddAccountPage composable to AddAccountScreen * refactor(folio): rename LedgerPage composable to LedgerScreen * refactor(folio): rename AddTransactionPage composable to AddTransactionScreen * refactor(folio): split Models.kt into app.folio.data package Account, Transaction (with TxnType), and Session move into their own files under app.folio.data, matching NowInAndroid-style per-type organization. * refactor(folio): move data layer into app.folio.data package Repository, LedgerStore (expect + interface), SqlLedgerStore, WebLedgerStore, DriverFactory (expect + actuals), AndroidLedgerContext, and Snapshot move into app.folio.data. Update all consumer imports. * refactor(folio): move Navigation into app.folio.navigation package Split the former Navigation.kt into Route.kt (sealed interface) and Navigator.kt (singleton). Update consumer imports across screens, App.kt, and FolioApplication. * refactor(folio): move Platform and Format into app.folio.platform Both files carry expect declarations (Platform object, formatDate); grouping them into a dedicated platform package makes the KMP seam obvious and mirrors the structure used by JetBrains samples. * refactor(folio): move login into feature/auth package Create app.folio.feature.auth with LoginScreen + LoginUiState. Inline the former Auth.kt (DEMO_EMAIL, DEMO_PASSWORD, checkCredentials) into LoginScreen since it is the sole caller. * refactor(folio): move HomeScreen into feature/home package * refactor(folio): move account creation into feature/account package AddAccountScreen gets its own AddAccountUiState colocated with the screen, replacing the shared UiState.addAccountError. * refactor(folio): move ledger screens into feature/ledger package LedgerScreen and AddTransactionScreen move into app.folio.feature.ledger with AddTransactionUiState (txnError, txnFormType) colocated. The former catch-all UiState.kt is removed now that each screen owns its state alongside its UI. * refactor(folio): split Theme.kt; move theme and icons to subpackages Theme split into Theme.kt (tokens, layout dims, LedgerTheme) and Type.kt (typography) under app.folio.ui.theme. Icons moves to app.folio.ui.icon. Update every consumer's imports to match. * refactor(folio): split ui components into per-file under ui/component Former Widgets.kt and Components.kt become 10 focused files: AppButton, Card, EmptyState, ErrorText, FieldLabel, Header, IconButton (w/ BackButton), Screen, Segmented, TextInput. Matches NowInAndroid style of one composable per file in a designsystem/component package. * chore(folio): consolidate uatu testing files under uatu/ folder Move spec.ts, package.json, package-lock.json into examples/folio/uatu so all uatu-specific testing artifacts live in one place. runs/ and node_modules/ follow the same convention (both remain gitignored). Update justfile, README, and the two Go consumers (bundle-check tool + verifier/trace tests) that referenced the old path. * refactor(trace): drop folio path in writer test Round-trip only needs a non-empty string; neutralize to keep the library free of folio references. * refactor(sidecar): neutralize ResolveActivity test fixtures Swap app.folio for com.example.app in the fixture strings so the sidecar tests don't reference the example app by name. * refactor(bundle-check): take spec path as argument Previously the tool hardcoded examples/folio/uatu/spec.ts. Accept a positional spec path instead so the tool works for any example and leaves no folio reference in the library surface. * test(verifier): add neutral integration spec and hierarchy fixtures Adds testdata/integration_spec.ts with two routes ("list", "form"), an InputText on text_field, a Tap on primary/secondary_action, a safety property (itemCountNonNegative), and a liveness property (submitEventually). Adds hierarchies_test.go with matching XML fixtures. Constants intentionally go in a _test.go at package root rather than testdata/hierarchies.go because go skips .go files under testdata/. * refactor(verifier): replace folio integration tests with neutral ones Renames bundleFolioSpec -> bundleIntegrationSpec and the three Test* entry points to TestIntegrationSpec*. Uses the synthetic spec and hierarchies added in the previous commit so the library's test suite no longer references examples/folio at all. Folio-specific coverage remains covered by examples/folio/justfile's 'just test' which exercises the real spec on device/emulator. * chore: remove cmd/uatu-sample-harness Not referenced by Makefile, docs, CI, or any script. Duplicates the adb reverse helpers already in cmd/uatu/test_run.go, and its name implies ownership by the sample app which violates the library/ example decoupling. If a bare-protocol debugging tool is later needed it belongs inside cmd/uatu/. * docs(folio): drop Layout section and KMP layout paragraph; fix AVD override syntax The directory-tree Layout section rots faster than the code and duplicates what ls shows for free. The expect/actual paragraph in Stack was the same kind of filler. The README also claimed 'just AVD=Pixel_7 test' but the justfile reads AVD as an env var via env_var_or_default, so the correct invocation is 'AVD=Pixel_7 just test'.
442 lines
12 KiB
TypeScript
442 lines
12 KiB
TypeScript
import {
|
|
InputText,
|
|
Tap,
|
|
actions,
|
|
always,
|
|
eventually,
|
|
extract,
|
|
from,
|
|
next,
|
|
now,
|
|
pressKey,
|
|
swipes,
|
|
taps,
|
|
waitOnce,
|
|
weighted,
|
|
} from "@uatu/spec";
|
|
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
|
|
|
|
interface AccountSnapshot {
|
|
id: string;
|
|
name: string;
|
|
balance: number;
|
|
txnCount: number;
|
|
}
|
|
|
|
interface LedgerRow {
|
|
id: string;
|
|
accountId: string;
|
|
type: "credit" | "debit";
|
|
amount: number;
|
|
signed: number;
|
|
}
|
|
|
|
const loggedIn = extract<boolean>(
|
|
(state) => (state.snapshots.logged_in as boolean) ?? false,
|
|
);
|
|
const authStatus = extract<string>(
|
|
(state) => (state.snapshots.auth_status as string) ?? "",
|
|
);
|
|
const route = extract<string>(
|
|
(state) => (state.snapshots.route as string) ?? "",
|
|
);
|
|
const accounts = extract<AccountSnapshot[]>(
|
|
(state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [],
|
|
);
|
|
const totalBalance = extract<number>(
|
|
(state) => (state.snapshots.total_balance as number) ?? 0,
|
|
);
|
|
const accountCount = extract<number>(
|
|
(state) => (state.snapshots.account_count as number) ?? 0,
|
|
);
|
|
const activeAccountId = extract<string | null>(
|
|
(state) => (state.snapshots.active_account_id as string | null) ?? null,
|
|
);
|
|
const ledgerRows = extract<LedgerRow[]>(
|
|
(state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [],
|
|
);
|
|
const ledgerBalance = extract<number>(
|
|
(state) => (state.snapshots.ledger_balance as number) ?? 0,
|
|
);
|
|
const focusedInput = extract<string | null>(
|
|
(state) => (state.snapshots.focused_input as string | null) ?? null,
|
|
);
|
|
const txnFormType = extract<string | null>(
|
|
(state) => (state.snapshots.txn_form_type as string | null) ?? null,
|
|
);
|
|
const txnFormAccountId = extract<string | null>(
|
|
(state) => (state.snapshots.txn_form_account_id as string | null) ?? null,
|
|
);
|
|
const loginError = extract<string>(
|
|
(state) => (state.snapshots.login_error as string) ?? "",
|
|
);
|
|
const addAccountError = extract<string>(
|
|
(state) => (state.snapshots.add_account_error as string) ?? "",
|
|
);
|
|
const txnError = extract<string>(
|
|
(state) => (state.snapshots.txn_error as string) ?? "",
|
|
);
|
|
const loginEmailField = extract((state) => state.ax.find("desc:login_email"));
|
|
const loginPasswordField = extract((state) => state.ax.find("desc:login_password"));
|
|
const loginSubmitButton = extract((state) => state.ax.find("desc:login_submit"));
|
|
const addAccountButton = extract((state) => state.ax.find("desc:add_account_button"));
|
|
const logoutButton = extract((state) => state.ax.find("desc:logout_button"));
|
|
const accountNameField = extract((state) => state.ax.find("desc:account_name_field"));
|
|
const addAccountSubmit = extract((state) => state.ax.find("desc:add_account_submit"));
|
|
const addTxnButton = extract((state) => state.ax.find("desc:add_txn_button"));
|
|
const txnAmountField = extract((state) => state.ax.find("desc:txn_amount"));
|
|
const txnNoteField = extract((state) => state.ax.find("desc:txn_note"));
|
|
const txnCredit = extract((state) => state.ax.find("desc:txn_credit"));
|
|
const txnDebit = extract((state) => state.ax.find("desc:txn_debit"));
|
|
const txnSubmit = extract((state) => state.ax.find("desc:txn_submit"));
|
|
const backButton = extract((state) => state.ax.find("desc:Back"));
|
|
const allAccountCards = extract((state) =>
|
|
state.ax.findAll("descPrefix:account_card:"),
|
|
);
|
|
|
|
const accountCountNonNegative = always(() => accountCount.current >= 0);
|
|
|
|
const onHome = () => route.current === "home";
|
|
const onLedger = () =>
|
|
route.current === "ledger" || route.current === "add-transaction";
|
|
const isInteger = (n: number) => Number.isFinite(n) && Math.floor(n) === n;
|
|
|
|
const totalBalanceMatchesAccounts = always(
|
|
now(onHome).implies(
|
|
now(() => {
|
|
const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0);
|
|
return sum === totalBalance.current;
|
|
}),
|
|
),
|
|
);
|
|
|
|
const ledgerBalanceMatchesRows = always(
|
|
now(onLedger).implies(
|
|
now(() => {
|
|
const sum = ledgerRows.current.reduce((acc, r) => acc + r.signed, 0);
|
|
return sum === ledgerBalance.current;
|
|
}),
|
|
),
|
|
);
|
|
|
|
const ledgerRowsWellFormed = always(() => {
|
|
for (const row of ledgerRows.current) {
|
|
if (row.type !== "credit" && row.type !== "debit") return false;
|
|
if (!(row.amount > 0)) return false;
|
|
const expected = row.type === "credit" ? row.amount : -row.amount;
|
|
if (row.signed !== expected) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const balancesAreIntegerCents = always(() => {
|
|
if (!isInteger(totalBalance.current)) return false;
|
|
if (!isInteger(ledgerBalance.current)) return false;
|
|
for (const a of accounts.current) if (!isInteger(a.balance)) return false;
|
|
for (const r of ledgerRows.current) {
|
|
if (!isInteger(r.amount) || !isInteger(r.signed)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const accountCountMatchesList = always(
|
|
() => accountCount.current === accounts.current.length,
|
|
);
|
|
|
|
const ledgerCountMatchesRows = always(
|
|
now(onLedger).implies(
|
|
now(() => {
|
|
const active = activeAccountId.current;
|
|
if (active === null) return true;
|
|
const fromAccounts = accounts.current.find((a) => a.id === active);
|
|
if (!fromAccounts) return true;
|
|
return fromAccounts.txnCount === ledgerRows.current.length;
|
|
}),
|
|
),
|
|
);
|
|
|
|
const zeroTxnsMeansZeroBalance = always(() => {
|
|
for (const a of accounts.current) {
|
|
if (a.txnCount === 0 && a.balance !== 0) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const noOrphanTransactions = always(() => {
|
|
const active = activeAccountId.current;
|
|
if (active === null) return ledgerRows.current.length === 0;
|
|
return ledgerRows.current.every((r) => r.accountId === active);
|
|
});
|
|
|
|
const uniqueAccountNames = always(() => {
|
|
const seen = new Set<string>();
|
|
for (const a of accounts.current) {
|
|
const key = a.name.trim().toLowerCase();
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const accountingInvariants = {
|
|
totalBalanceMatchesAccounts,
|
|
ledgerBalanceMatchesRows,
|
|
ledgerRowsWellFormed,
|
|
balancesAreIntegerCents,
|
|
accountCountMatchesList,
|
|
ledgerCountMatchesRows,
|
|
zeroTxnsMeansZeroBalance,
|
|
noOrphanTransactions,
|
|
uniqueAccountNames,
|
|
};
|
|
|
|
const accountsOnlyGrow = always(
|
|
now(() => true).implies(
|
|
next(() => accounts.current.length >= (accounts.previous?.length ?? 0)),
|
|
),
|
|
);
|
|
|
|
const ledgerOnlyGrowsPerAccount = always(
|
|
now(() => activeAccountId.current !== null).implies(
|
|
next(() => {
|
|
if (activeAccountId.current !== activeAccountId.previous) return true;
|
|
return ledgerRows.current.length >= (ledgerRows.previous?.length ?? 0);
|
|
}),
|
|
),
|
|
);
|
|
|
|
const authStatusIsKnown = always(
|
|
() => authStatus.current === "logged-in" || authStatus.current === "logged-out",
|
|
);
|
|
|
|
const routeIsKnown = always(() => {
|
|
const r = route.current;
|
|
return (
|
|
r === "login" ||
|
|
r === "home" ||
|
|
r === "add-account" ||
|
|
r === "ledger" ||
|
|
r === "add-transaction"
|
|
);
|
|
});
|
|
|
|
const loggedInLeavesLogin = always(
|
|
now(() => loggedIn.current).implies(
|
|
eventually(() => route.current !== "login").within(3, "seconds"),
|
|
),
|
|
);
|
|
|
|
const loggedOutReachesLogin = always(
|
|
now(() => !loggedIn.current).implies(
|
|
eventually(() => route.current === "login").within(3, "seconds"),
|
|
),
|
|
);
|
|
|
|
const stateMachine = {
|
|
accountsOnlyGrow,
|
|
ledgerOnlyGrowsPerAccount,
|
|
authStatusIsKnown,
|
|
routeIsKnown,
|
|
loggedInLeavesLogin,
|
|
loggedOutReachesLogin,
|
|
};
|
|
|
|
const loginReachable = eventually(() => loggedIn.current).within(90, "seconds");
|
|
const accountCreationReachable = eventually(
|
|
() => accounts.current.length > 0,
|
|
).within(180, "seconds");
|
|
const someTransactionExists = eventually(() =>
|
|
accounts.current.some((a) => a.txnCount > 0),
|
|
).within(300, "seconds");
|
|
|
|
const loginErrorClears = always(
|
|
now(() => loginError.current !== "").implies(
|
|
eventually(() => loginError.current === "").within(30, "seconds"),
|
|
),
|
|
);
|
|
const addAccountErrorClears = always(
|
|
now(() => addAccountError.current !== "").implies(
|
|
eventually(() => addAccountError.current === "").within(30, "seconds"),
|
|
),
|
|
);
|
|
const txnErrorClears = always(
|
|
now(() => txnError.current !== "").implies(
|
|
eventually(() => txnError.current === "").within(30, "seconds"),
|
|
),
|
|
);
|
|
|
|
const liveness = {
|
|
loginReachable,
|
|
accountCreationReachable,
|
|
someTransactionExists,
|
|
loginErrorClears,
|
|
addAccountErrorClears,
|
|
txnErrorClears,
|
|
};
|
|
|
|
const DEMO_EMAIL = "[email protected]";
|
|
const DEMO_PASSWORD = "ledger123";
|
|
|
|
const loginHelper = actions(() => {
|
|
if (loggedIn.current) return [];
|
|
const focus = focusedInput.current;
|
|
const email = loginEmailField.current;
|
|
const password = loginPasswordField.current;
|
|
const submit = loginSubmitButton.current;
|
|
|
|
if (focus === "login_password") {
|
|
return submit ? [Tap({ on: submit })] : [];
|
|
}
|
|
if (focus === "login_email") {
|
|
return password ? [InputText({ into: password, text: DEMO_PASSWORD })] : [];
|
|
}
|
|
return email ? [InputText({ into: email, text: DEMO_EMAIL })] : [];
|
|
});
|
|
|
|
const adversarialLogin = actions(() => {
|
|
if (loggedIn.current) return [];
|
|
if (focusedInput.current !== null) return [];
|
|
const submit = loginSubmitButton.current;
|
|
if (!submit) return [];
|
|
return [Tap({ on: submit })];
|
|
});
|
|
|
|
const accountNameSampler = from([
|
|
"Checking",
|
|
"Savings",
|
|
"Travel",
|
|
"Rent",
|
|
"Emergency Fund",
|
|
"Investments",
|
|
"Groceries",
|
|
" ",
|
|
"Checking",
|
|
"A".repeat(41),
|
|
"Petty Cash",
|
|
]);
|
|
|
|
const typeAccountName = actions(() => {
|
|
if (route.current !== "add-account") return [];
|
|
const field = accountNameField.current;
|
|
if (!field) return [];
|
|
return [InputText({ into: field, text: accountNameSampler.generate() })];
|
|
});
|
|
|
|
const submitAddAccount = actions(() => {
|
|
if (route.current !== "add-account") return [];
|
|
const submit = addAccountSubmit.current;
|
|
return submit ? [Tap({ on: submit })] : [];
|
|
});
|
|
|
|
const openAddAccount = actions(() => {
|
|
if (route.current !== "home") return [];
|
|
const button = addAccountButton.current;
|
|
return button ? [Tap({ on: button })] : [];
|
|
});
|
|
|
|
const openRandomAccount = actions(() => {
|
|
if (route.current !== "home") return [];
|
|
const cards = allAccountCards.current;
|
|
if (cards.length === 0) return [];
|
|
const card = cards[Math.floor(Math.random() * cards.length)];
|
|
return [Tap({ on: card })];
|
|
});
|
|
|
|
const logoutAction = actions(() => {
|
|
if (route.current !== "home") return [];
|
|
const button = logoutButton.current;
|
|
return button ? [Tap({ on: button })] : [];
|
|
});
|
|
|
|
const goBack = actions(() => {
|
|
const button = backButton.current;
|
|
return button ? [Tap({ on: button })] : [];
|
|
});
|
|
|
|
const amountSampler = from([
|
|
"12.34",
|
|
"100",
|
|
"0.01",
|
|
"999.99",
|
|
"5.5",
|
|
"42",
|
|
"0",
|
|
"",
|
|
"1e4",
|
|
"0.001",
|
|
"-5",
|
|
]);
|
|
|
|
const typeAmount = actions(() => {
|
|
if (route.current !== "add-transaction") return [];
|
|
const field = txnAmountField.current;
|
|
if (!field) return [];
|
|
return [InputText({ into: field, text: amountSampler.generate() })];
|
|
});
|
|
|
|
const noteSampler = from([
|
|
"Coffee",
|
|
"Paycheck",
|
|
"Gas",
|
|
"Refund",
|
|
"",
|
|
"Groceries for the week",
|
|
]);
|
|
|
|
const typeNote = actions(() => {
|
|
if (route.current !== "add-transaction") return [];
|
|
const field = txnNoteField.current;
|
|
if (!field) return [];
|
|
return [InputText({ into: field, text: noteSampler.generate() })];
|
|
});
|
|
|
|
const toggleTxnType = actions(() => {
|
|
if (route.current !== "add-transaction") return [];
|
|
const current = txnFormType.current;
|
|
const target = current === "credit" ? txnDebit.current : txnCredit.current;
|
|
return target ? [Tap({ on: target })] : [];
|
|
});
|
|
|
|
const submitTxn = actions(() => {
|
|
if (route.current !== "add-transaction") return [];
|
|
const submit = txnSubmit.current;
|
|
return submit ? [Tap({ on: submit })] : [];
|
|
});
|
|
|
|
const openAddTxn = actions(() => {
|
|
if (route.current !== "ledger") return [];
|
|
const button = addTxnButton.current;
|
|
return button ? [Tap({ on: button })] : [];
|
|
});
|
|
|
|
export const properties = {
|
|
accountCountNonNegative,
|
|
...accountingInvariants,
|
|
...stateMachine,
|
|
...liveness,
|
|
noUncaughtExceptions,
|
|
};
|
|
|
|
export const actionsRoot = weighted(
|
|
[30, loginHelper],
|
|
[2, adversarialLogin],
|
|
[18, typeAccountName],
|
|
[14, submitAddAccount],
|
|
[18, typeAmount],
|
|
[8, typeNote],
|
|
[6, toggleTxnType],
|
|
[16, submitTxn],
|
|
[14, openAddAccount],
|
|
[14, openRandomAccount],
|
|
[12, openAddTxn],
|
|
[6, goBack],
|
|
[1, logoutAction],
|
|
[4, taps],
|
|
[2, swipes],
|
|
[2, waitOnce],
|
|
[2, pressKey],
|
|
);
|
|
|
|
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
|
|
(globalThis as { properties?: unknown }).properties = properties;
|