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
4 changes: 3 additions & 1 deletion backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ if [ "$CMD" == "restore" ]; then
else
echo "Running backup to $DIR..."
git rev-parse HEAD > $DIR/commit.txt
dfx canister --network ic call taggr backup
if [ "$PAGE_START" -eq 0 ]; then
dfx canister --network ic call taggr backup
fi
$BACKUP $DIR backup "6qfxa-ryaaa-aaaai-qbhsq-cai" $PAGE_START
fi

28 changes: 17 additions & 11 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,18 +258,24 @@ export async function waitForTextContent(
} = {},
): Promise<void> {
const { timeout = 10000 } = options;

await page.waitForFunction(
({ sel, expected }) => {
const element = document.querySelector(sel);
if (!element) return false;
const text = element.textContent || "";
return typeof expected === "string"
? text.includes(expected)
: new RegExp(expected).test(text);
const maxAttempts = Math.ceil(timeout / 500);

await pollForCondition(
async () => {
const element = page.locator(selector).first();
const count = await element.count();
if (count === 0) return false;
const text = await element.textContent();
if (!text) return false;
return typeof expectedText === "string"
? text.includes(expectedText)
: new RegExp(expectedText).test(text);
},
{
maxAttempts,
interval: 500,
errorMessage: `Text content "${expectedText}" not found in selector "${selector}" within ${timeout}ms`,
},
{ sel: selector, expected: expectedText.toString() },
{ timeout },
);
}

Expand Down
3 changes: 0 additions & 3 deletions e2e/test1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,6 @@ test.describe("Upgrades & token transfer flow", () => {
test("Verify recovery upgrade", async () => {
await page.goto("/#/dashboard");
await waitForUILoading(page);
await page.getByRole("button", { name: "TECHNICAL" }).click();
await waitForUILoading(page);

await pollForCondition(
async () => {
Expand Down Expand Up @@ -354,7 +352,6 @@ test.describe("Upgrades & token transfer flow", () => {
await page.reload();
await page.waitForURL(/dashboard/);
await waitForUILoading(page);
await page.getByRole("button", { name: "TECHNICAL" }).click();
const count = await page
.locator("p", { hasText: /Upgrade succeeded/ })
.count();
Expand Down
26 changes: 15 additions & 11 deletions e2e/test2.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { waitForUILoading } from "./helpers";
import { waitForUILoading, pollForCondition } from "./helpers";
import { test, expect, Page } from "@playwright/test";
import { resolve } from "node:path";
import { mkPwd, transferICP } from "./command";
Expand Down Expand Up @@ -179,17 +179,21 @@ test.describe("Regular users flow", () => {
},
);

await page.waitForFunction(
(oldBalance) => {
const elem = document.querySelector(
'[data-testid="icp-balance"]',
);
if (!elem) return false;
const currentBalance = parseFloat(elem.textContent || "0");
return currentBalance < oldBalance;
await pollForCondition(
async () => {
const elem = page.getByTestId("icp-balance");
const count = await elem.count();
if (count === 0) return false;
const text = await elem.textContent();
if (!text) return false;
const currentBalance = parseFloat(text);
return currentBalance < icpBalance;
},
{
maxAttempts: 20,
interval: 500,
errorMessage: `ICP balance did not decrease from ${icpBalance} within timeout`,
},
icpBalance,
{ timeout: 10000 },
);

const newBalance = parseFloat(
Expand Down
50 changes: 30 additions & 20 deletions e2e/test3.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { waitForUILoading, handleDialog } from "./helpers";
import { waitForUILoading, handleDialog, pollForCondition } from "./helpers";
import { test, expect, Page } from "@playwright/test";
import { exec, mkPwd, transferICP } from "./command";

Expand Down Expand Up @@ -166,6 +166,7 @@ test.describe("Regular users flow, part two", () => {
await page.getByTestId("toggle-user-section").click();
await expect(page.getByTestId("token-balance")).toHaveText("5");
await page.getByTestId("toggle-user-section").click();
await waitForUILoading(page);

const post = page
.getByTestId("post-body")
Expand All @@ -174,7 +175,7 @@ test.describe("Regular users flow, part two", () => {
})
.last();
await expect(post).toBeVisible();
const menuBTN = post.locator(`button[title="Menu"]`);
const menuBTN = post.getByTestId("post-info-toggle");
await expect(menuBTN).toBeVisible();
await menuBTN.click();
const postMenu = post.getByTestId("post-menu");
Expand All @@ -185,23 +186,30 @@ test.describe("Regular users flow, part two", () => {
await expect(popup).toHaveText(/Tip john with.*/);
await popup.locator("input").fill("1");

await handleDialog(page, /./, "", async () => {
await popup.getByText("SEND").click();
});
await waitForUILoading(page);
// Click SEND to show confirmation
await popup.getByText("SEND").click();

await page.goto("/");
await waitForUILoading(page);
await page.getByTestId("toggle-user-section").click();
// Wait for confirmation UI to appear and click CONFIRM
await expect(popup.getByText("CONFIRM")).toBeVisible();
await popup.getByText("CONFIRM").click();

await pollForCondition(
async () => {
await page.goto("/");
await page.getByTestId("toggle-user-section").click();

await page.waitForFunction(
() => {
const elem = document.querySelector(
'[data-testid="token-balance"]',
);
return elem?.textContent === "4";
const elem = page.getByTestId("token-balance");
const count = await elem.count();
if (count === 0) return false;
const text = await elem.textContent();
return text === "4";
},
{
maxAttempts: 20,
interval: 500,
errorMessage:
"Token balance did not update to 4 within timeout",
},
{ timeout: 10000 },
);

await expect(page.getByTestId("token-balance")).toHaveText("4");
Expand All @@ -215,7 +223,7 @@ test.describe("Regular users flow, part two", () => {
})
.last();
await expect(post).toBeVisible();
const menuBTN = post.locator(`button[title="Menu"]`);
const menuBTN = post.getByTestId("post-info-toggle");
await expect(menuBTN).toBeVisible();
await menuBTN.click();
const postMenu = post.getByTestId("post-menu");
Expand All @@ -225,11 +233,13 @@ test.describe("Regular users flow, part two", () => {
await expect(popup).toBeVisible();
await popup.locator("input").fill("1");

page.once("dialog", async (dialog) => {
await dialog.dismiss();
});
// Click SEND to show confirmation
await popup.getByText("SEND").click();

// Wait for confirmation UI to appear and click CANCEL
await expect(popup.getByText("CANCEL")).toBeVisible();
await popup.getByText("CANCEL").click();

await page.goto("/");
await waitForUILoading(page);
await page.getByTestId("toggle-user-section").click();
Expand Down
1 change: 1 addition & 0 deletions e2e/test4.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ test.describe("Report and transfer to user", () => {
"e93e7f1cfa411dafa8debb4769c6cc1b7972434f1669083fd08d86d11c0c0722",
1,
);
await waitForUILoading(page);
await page
.getByRole("button", { name: "MINT CREDITS WITH ICP" })
.click();
Expand Down
75 changes: 42 additions & 33 deletions src/backend/env/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ use std::collections::HashSet;
use candid::Principal;
use serde::{Deserialize, Serialize};

use crate::mutate;
use crate::{
env::{post::Meta, Time, YEAR},
mutate,
};

use super::{
config::CONFIG,
post::{Extension, Post, PostId},
token::Token,
user::UserId,
Expand All @@ -18,47 +22,52 @@ pub struct Feature {
pub supporters: HashSet<UserId>,
// 0: requested, 1: implemented
pub status: u8,
#[serde(default)]
pub last_activity: Time,
}

/// Returns a list of all feature ids and current collective voting power of all supporters.
pub fn features<'a>(
state: &'a State,
ids: &'a [PostId],
) -> Box<dyn DoubleEndedIterator<Item = (PostId, Token, Feature)> + 'a> {
let count_support = move |(post_id, feature): (&PostId, Feature)| {
(
*post_id,
feature
.supporters
.iter()
.map(|user_id| {
state
.users
.get(user_id)
.map(|user| user.total_balance())
.unwrap_or_default()
})
.sum::<Token>(),
feature,
)
now: Time,
) -> Box<dyn DoubleEndedIterator<Item = ((&'a Post, Meta<'a>), Token, Feature)> + 'a> {
let transform_feature = move |(post_id, feature): (&PostId, Feature)| {
if feature.last_activity + YEAR <= now {
return None;
}
let tokens = feature
.supporters
.iter()
.map(|user_id| {
state
.users
.get(user_id)
.map(|user| user.total_balance())
.unwrap_or_default()
})
.sum::<Token>();
Post::get(state, post_id).map(|post| (post.with_meta(state), tokens, feature))
};

if !ids.is_empty() {
return Box::new(
ids.iter()
.filter_map(move |id| state.memory.features.get(id).map(|feature| (id, feature)))
.map(count_support),
.filter_map(transform_feature),
);
}
Box::new(state.memory.features.iter().map(count_support))
Box::new(state.memory.features.iter().filter_map(transform_feature))
}

pub fn toggle_feature_support(caller: Principal, post_id: PostId) -> Result<(), String> {
pub fn toggle_feature_support(caller: Principal, post_id: PostId, now: Time) -> Result<(), String> {
mutate(|state| {
let user_id = state.principal_to_user(caller).ok_or("no user found")?.id;
let mut feature = state.memory.features.remove(&post_id)?;
if feature.supporters.contains(&user_id) {
feature.supporters.remove(&user_id);
} else {
feature.last_activity = now;
feature.supporters.insert(user_id);
}
state
Expand All @@ -71,19 +80,15 @@ pub fn toggle_feature_support(caller: Principal, post_id: PostId) -> Result<(),
})
}

pub fn create_feature(caller: Principal, post_id: PostId) -> Result<(), String> {
pub fn create_feature(caller: Principal, post_id: PostId, now: Time) -> Result<(), String> {
mutate(|state| {
let user = state.principal_to_user(caller).ok_or("no user found")?;
let user_name = user.name.clone();

if !Post::get(state, &post_id)
.map(|post| {
post.user == user.id && matches!(post.extension.as_ref(), Some(&Extension::Feature))
})
.unwrap_or_default()
{
let post = Post::get(state, &post_id).ok_or("post not found")?;
if post.user != user.id || !matches!(post.extension.as_ref(), Some(&Extension::Feature)) {
return Err("no post with a feature found".into());
};
}

if state.memory.features.get(&post_id).is_some() {
return Err("feature already exists".into());
Expand All @@ -97,14 +102,18 @@ pub fn create_feature(caller: Principal, post_id: PostId) -> Result<(), String>
Feature {
supporters: Default::default(),
status: 0,
last_activity: now,
},
)
.expect("couldn't persist feature");

state.logger.info(format!(
"@{} created a [new feature](#/post/{})",
user_name, post_id
));
let _ = state.system_message(
format!(
"A [new feature](#/post/{}) was created by `@{}`",
post_id, user_name
),
CONFIG.dao_realm.into(),
);

Ok(())
})
Expand Down
Loading