cleanup(shopfloor): session_swap is the only tablet flow

Frontend cleanup completing Phase G of the tablet PIN session
redesign:

- tablet_lock.js: removed sessionMode branching (no legacy path).
  unlock() always calls /fp/tablet/unlock_session + reloads.
  handOff() always calls tabletSessionManager.lockBack('manual').
  isLocked uses currentUid vs kioskUid exclusively. _checkIdle
  still drives the warning UI via activity_tracker; the actual
  lock RPC is owned by tablet_session_manager.

- fp_rpc.js: simplified to a thin async pass-through around @web/core
  network rpc. tech_store-based tablet_tech_id injection is gone
  (the session uid IS the tech).

- tech_store.js: DELETED (replaced by per-session backend attribution
  + tablet_session_manager for lock state). Removed from manifest.

- Wrapper components (shopfloor_landing, job_workspace,
  manager_dashboard, plant_kanban): swapped useService('fp_shopfloor_tech_store')
  for useService('fp_tablet_session_manager'); techStore.lock() ->
  tabletSessionManager.lockBack('manual'). plant_kanban's defensive
  try/catch on the tech_store lookup is no longer needed.

- tablet_lock.xml: Hand-Off button no longer gated on sessionMode;
  always rendered.

- Tests: removed legacy TestTabletUnlock class from test_tablet_pin.py
  (covered the deleted /fp/tablet/unlock route). Dropped session_mode
  assertion from test_tiles_bootstrap_fields.py (the return key is
  gone post-Phase-G). kiosk_uid + current_uid assertions retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 14:36:12 -04:00
parent d9f2983ea7
commit 67fc22237b
11 changed files with 67 additions and 280 deletions

View File

@@ -86,13 +86,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss', 'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml', 'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js', 'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
# ---- Phase 6.2 tablet PIN gate ---- # ---- Tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js', 'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
# Phase D — tablet session manager (idle + ceiling timer). # Tablet session manager (idle + ceiling timer). Calls
# Used by tablet_lock when fp.shopfloor.tablet_session_mode # /fp/tablet/lock_session and reloads the page so the
# ='session_swap' to call /fp/tablet/lock_session and reload # browser re-bootstraps under the kiosk.
# the page so the browser re-bootstraps under the kiosk.
'fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js', 'fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js',
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer # Phase 6.3 — fpRpc wrapper. MUST load before any consumer
# (job_workspace, shopfloor_landing, manager_dashboard, # (job_workspace, shopfloor_landing, manager_dashboard,

View File

@@ -37,7 +37,7 @@ export class FpJobWorkspace extends Component {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.dialog = useService("dialog"); this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store"); this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({ this.state = useState({
data: null, data: null,
@@ -105,7 +105,7 @@ export class FpJobWorkspace extends Component {
// ---- Hand-Off (Phase 6.2) --------------------------------------------- // ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() { handOff() {
this.techStore.lock(); this.tabletSessionManager.lockBack("manual");
} }
onJumpToBlocker({ model, id }) { onJumpToBlocker({ model, id }) {

View File

@@ -28,7 +28,7 @@ export class ManagerDashboard extends Component {
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store"); this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({ this.state = useState({
overview: null, overview: null,
@@ -153,7 +153,7 @@ export class ManagerDashboard extends Component {
// ---- Hand-Off (Phase 6.2) --------------------------------------------- // ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() { handOff() {
this.techStore.lock(); this.tabletSessionManager.lockBack("manual");
} }
toggleCard(jobId) { toggleCard(jobId) {

View File

@@ -40,12 +40,7 @@ export class FpPlantKanban extends Component {
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
// techStore may not be registered until first PIN unlock; guard with try. this.tabletSessionManager = useService("fp_tablet_session_manager");
try {
this.techStore = useService("fp_shopfloor_tech_store");
} catch {
this.techStore = null;
}
this.state = useState({ this.state = useState({
mode: "station", mode: "station",
@@ -142,9 +137,7 @@ export class FpPlantKanban extends Component {
} }
onHandOff() { onHandOff() {
if (this.techStore && this.techStore.lock) { this.tabletSessionManager.lockBack("manual");
this.techStore.lock();
}
} }
onScanQr() { onScanQr() {

View File

@@ -1,74 +1,15 @@
/** @odoo-module **/ /** @odoo-module **/
// ============================================================================= // =============================================================================
// Fusion Plating — fpRpc() wrapper // fpRpc — thin wrapper around @web/core/network/rpc
// //
// Drop-in replacement for the standard `rpc()` import. Automatically // Post-Phase-G of the tablet PIN session redesign: this no longer
// injects the current tablet_tech_id from the tech_store into every // injects tablet_tech_id (the session uid IS the tech). Kept as a
// call, so server-side endpoints can attribute the action to the right // thin pass-through for backwards compatibility with callers that
// user via env.with_user() (see env_for_tablet_tech in // import fpRpc; a future cleanup could remove the wrapper entirely
// controllers/_tablet_audit.py). // and update callers to use `rpc` directly.
//
// USE for any RPC that WRITES (start step, finish step, hold create,
// sign-off, milestone advance). For read-only loads (kanban, workspace
// load, manager funnel), plain rpc() is fine.
//
// Example:
// import { fpRpc } from "../services/fp_rpc";
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
//
// Phase D — in `session_swap` mode, the tablet operates on a REAL Odoo
// session minted by /fp/tablet/unlock_session whose uid IS the tech.
// In that mode tablet_tech_id is redundant; the server attributes the
// write to request.env.user directly. We cache the mode at module
// level (refresh on every page load, exactly when the session may
// have flipped).
// ============================================================================= // =============================================================================
import { rpc } from "@web/core/network/rpc";
import { rpc as baseRpc } from "@web/core/network/rpc";
// Cached once per page load. Invalidated naturally by window.location.reload()
// after every lock/unlock (the JS bundle reinitializes, cache resets to null).
let _sessionModeCache = null; // 'legacy' | 'session_swap' | null (unknown)
async function _getSessionMode() {
if (_sessionModeCache !== null) return _sessionModeCache;
try {
const res = await baseRpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_session_mode", "legacy"],
kwargs: {},
});
_sessionModeCache = res || "legacy";
} catch (e) {
// If the lookup fails (network blip, ACL change), fail SAFE
// to legacy — that keeps tablet_tech_id injection on so the
// server-side audit attribution still works.
_sessionModeCache = "legacy";
}
return _sessionModeCache;
}
function _getTechStore() {
// Lazy-resolve via the global debug API — avoids circular service init
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
return env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore
}
return null;
}
export async function fpRpc(url, params = {}) { export async function fpRpc(url, params = {}) {
const mode = await _getSessionMode(); return rpc(url, params);
if (mode !== "session_swap") {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
}
return baseRpc(url, params);
} }

View File

@@ -1,42 +0,0 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);

View File

@@ -38,7 +38,7 @@ export class FpShopfloorLanding extends Component {
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store"); this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({ this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant", mode: localStorage.getItem(LS_MODE) || "all_plant",
@@ -126,7 +126,7 @@ export class FpShopfloorLanding extends Component {
// ---- Hand-Off (Phase 6.2) --------------------------------------------- // ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() { handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in // Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock(); this.tabletSessionManager.lockBack("manual");
} }
// ---- Search ------------------------------------------------------------ // ---- Search ------------------------------------------------------------

View File

@@ -31,11 +31,11 @@ export class FpTabletLock extends Component {
}; };
setup() { setup() {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity"); this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification"); this.notification = useService("notification");
// Phase D: idle + ceiling timer for session_swap mode. Started // Post-Phase-G: session_swap is the only flow. The tablet
// once tiles bootstrap shows we're already on a tech session. // session manager owns the actual idle-lock RPC; activity
// tracker drives the warning countdown UI.
this.tabletSessionManager = useService("fp_tablet_session_manager"); this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({ this.state = useState({
@@ -49,8 +49,9 @@ export class FpTabletLock extends Component {
clockText: this._formatTime(new Date()), clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()), dateText: this._formatDate(new Date()),
company: null, company: null,
// Phase D — feature flag + kiosk identity from bootstrap // Kiosk identity from bootstrap so we can tell when the
sessionMode: "legacy", // 'legacy' or 'session_swap' // current browser session belongs to a tech (= unlocked) vs.
// the kiosk (= locked).
kioskUid: null, kioskUid: null,
currentUid: null, currentUid: null,
}); });
@@ -58,11 +59,13 @@ export class FpTabletLock extends Component {
onMounted(async () => { onMounted(async () => {
await this._loadTiles(); await this._loadTiles();
this._tick = setInterval(() => this._checkIdle(), 1000); this._tick = setInterval(() => this._checkIdle(), 1000);
// Heartbeat ping every 60s — for forensic visibility // Heartbeat ping every 60s — for forensic visibility. Only
// ping while a tech is logged in; on the kiosk session this
// is just noise.
this._ping = setInterval(() => { this._ping = setInterval(() => {
if (this.techStore.currentTechId) { if (this.state.currentUid
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId }) && this.state.currentUid !== this.state.kioskUid) {
.catch(() => {}); rpc("/fp/tablet/ping", {}).catch(() => {});
} }
}, 60000); }, 60000);
// Clock tick — update visible HH:MM and date label every 60s. // Clock tick — update visible HH:MM and date label every 60s.
@@ -72,12 +75,11 @@ export class FpTabletLock extends Component {
this.state.clockText = this._formatTime(now); this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now); this.state.dateText = this._formatDate(now);
}, 60000); }, 60000);
// Session-swap mode: if we're already on a TECH session (uid // If we're already on a TECH session (uid != kiosk), start
// != kiosk), start the idle/ceiling timer immediately. This // the idle/ceiling timer immediately. This handles the case
// handles the case where the page was reloaded after // where the page was reloaded after unlock_session minted
// unlock_session minted the tech's session. // the tech's session.
if (this.state.sessionMode === "session_swap" if (this.state.currentUid
&& this.state.currentUid
&& this.state.currentUid !== this.state.kioskUid) { && this.state.currentUid !== this.state.kioskUid) {
this.tabletSessionManager.beginSession(); this.tabletSessionManager.beginSession();
} }
@@ -92,14 +94,10 @@ export class FpTabletLock extends Component {
} }
get isLocked() { get isLocked() {
// SESSION-SWAP MODE: the BROWSER session itself tells us whether // The browser session itself tells us whether a tech is
// a tech is unlocked — current_uid != kiosk_uid means unlocked. // unlocked — current_uid != kiosk_uid means unlocked.
// LEGACY MODE: defer to the techStore client-side flag. return !this.state.currentUid
if (this.state.sessionMode === "session_swap") { || this.state.currentUid === this.state.kioskUid;
return !this.state.currentUid
|| this.state.currentUid === this.state.kioskUid;
}
return this.techStore.isLocked;
} }
async _loadTiles() { async _loadTiles() {
@@ -109,9 +107,6 @@ export class FpTabletLock extends Component {
const res = await rpc("/fp/tablet/tiles", { station_id: stationId }); const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) { if (res && res.ok) {
this.state.company = res.company || null; this.state.company = res.company || null;
// Phase D — capture session_mode + kiosk/current uids so
// unlock() / isLocked / handOff can branch on mode.
this.state.sessionMode = res.tablet_session_mode || "legacy";
this.state.kioskUid = res.kiosk_uid || null; this.state.kioskUid = res.kiosk_uid || null;
this.state.currentUid = res.current_uid || null; this.state.currentUid = res.current_uid || null;
// Decorate each tile with an animation-delay (50ms staggered, // Decorate each tile with an animation-delay (50ms staggered,
@@ -130,14 +125,12 @@ export class FpTabletLock extends Component {
} }
_checkIdle() { _checkIdle() {
// In session_swap mode, the tablet_session_manager owns the idle // Activity tracker drives the warning countdown UI; the actual
// timer (it polls every 5s and calls /fp/tablet/lock_session // idle-lock RPC is owned by tablet_session_manager (it polls
// directly). Skip this legacy 1s-poll path to avoid two parallel // every 5s and calls /fp/tablet/lock_session directly). This
// idle systems competing on the same tech session. // path just updates the visible "auto-lock in N seconds" banner.
if (this.state.sessionMode === "session_swap") { if (!this.state.currentUid
return; || this.state.currentUid === this.state.kioskUid) {
}
if (!this.techStore.currentTechId) {
this.state.idleSecondsRemaining = null; this.state.idleSecondsRemaining = null;
return; return;
} }
@@ -163,36 +156,19 @@ export class FpTabletLock extends Component {
async unlock(pin) { async unlock(pin) {
try { try {
// SESSION-SWAP MODE: call the new endpoint, then reload the const res = await rpc("/fp/tablet/unlock_session", {
// page so the browser re-bootstraps under the tech's session.
if (this.state.sessionMode === "session_swap") {
const res = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
// Cookie has swapped. Reload so OWL/services re-init
// under the new (tech) session. The session manager
// (Task D1) picks up on the next page load.
// Match the legacy path's cleanup before reload kicks in.
this.state.selectedTileUserId = null;
window.location.reload();
// Return a pending state so the caller doesn't try to
// navigate while we're tearing down.
return { ok: true, reloading: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
}
// LEGACY MODE: existing /fp/tablet/unlock path
const res = await rpc("/fp/tablet/unlock", {
user_id: this.state.selectedTileUserId, user_id: this.state.selectedTileUserId,
pin, pin,
}); });
if (res && res.ok) { if (res && res.ok) {
this.techStore.setTech(res.current_tech_id, res.current_tech_name); // Cookie has swapped. Reload so OWL/services re-init
this.activity.bump(); // under the new (tech) session. The session manager
// picks up on the next page load.
this.state.selectedTileUserId = null; this.state.selectedTileUserId = null;
return { ok: true }; window.location.reload();
// Return a pending state so the caller doesn't try to
// navigate while we're tearing down.
return { ok: true, reloading: true };
} }
return { ok: false, error: (res && res.error) || "Unlock failed" }; return { ok: false, error: (res && res.error) || "Unlock failed" };
} catch (err) { } catch (err) {
@@ -205,17 +181,9 @@ export class FpTabletLock extends Component {
} }
handOff() { handOff() {
// SESSION-SWAP MODE: the server destroys the tech session, then // Server destroys the tech session, then we reload to
// we reload to re-bootstrap as the kiosk. // re-bootstrap as the kiosk.
if (this.state.sessionMode === "session_swap") { this.tabletSessionManager.lockBack("manual");
this.tabletSessionManager.lockBack("manual");
return;
}
// LEGACY MODE: client-side state flip only.
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;
this._loadTiles();
} }
// === 2026-05-24 redesign helpers ===================================== // === 2026-05-24 redesign helpers =====================================

View File

@@ -75,16 +75,10 @@
<t t-slot="default"/> <t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null" <FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/> secondsRemaining="state.idleSecondsRemaining"/>
<!-- Phase D: floating Hand-Off button for session_swap mode. <!-- Floating Hand-Off button. Post-Phase-G session_swap is
In legacy mode the wrapper components (landing/workspace/ the only flow; the button routes through the tablet
manager) own their own Hand-Off buttons that hit session manager + page reload. -->
techStore.lock(). In session_swap mode those become <button class="o_fp_lock_handoff_btn btn btn-warning"
no-ops (techStore isn't the source of truth anymore),
so we render a tablet_lock-owned button that calls
our own handOff() which routes through the session
manager + page reload. -->
<button t-if="state.sessionMode === 'session_swap'"
class="o_fp_lock_handoff_btn btn btn-warning"
t-on-click="handOff" t-on-click="handOff"
title="Lock the tablet for the next operator"> title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off <i class="fa fa-lock"/> Hand Off

View File

@@ -126,63 +126,6 @@ class TestTabletResetPinFor(HttpCase):
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash) self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.target = self.env['res.users'].create({
'name': 'Unlock Target', 'login': 'unlock@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def _unlock(self, pin):
return _rpc(self, '/fp/tablet/unlock',
user_id=self.target.id, pin=pin)
def test_unlock_correct_pin(self):
res = self._unlock('1234')
self.assertTrue(res['ok'])
self.assertEqual(res['current_tech_id'], self.target.id)
self.assertEqual(res['current_tech_name'], 'Unlock Target')
def test_unlock_correct_pin_resets_fail_counter(self):
self._unlock('0000') # fail once
self._unlock('1234') # succeed
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
def test_unlock_wrong_pin_increments_counter(self):
self._unlock('0000')
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
def test_lockout_after_5_fails(self):
for _ in range(5):
self._unlock('0000')
res = self._unlock('0000') # 6th
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
def test_lockout_blocks_even_correct_pin(self):
for _ in range(5):
self._unlock('0000')
# Even the correct PIN now rejected
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
def test_unlock_no_pin_set(self):
self.target.clear_tablet_pin()
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertTrue(res.get('needs_setup'))
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') @tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletTiles(HttpCase): class TestTabletTiles(HttpCase):
"""P6.1.4 — /fp/tablet/tiles endpoint.""" """P6.1.4 — /fp/tablet/tiles endpoint."""

View File

@@ -3,10 +3,11 @@ from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fp_tablet') @tagged('-at_install', 'post_install', 'fp_tablet')
class TestTilesBootstrapFields(HttpCase): class TestTilesBootstrapFields(HttpCase):
"""Phase D added 3 bootstrap fields to /fp/tablet/tiles: """Post-Phase-G /fp/tablet/tiles returns kiosk_uid + current_uid so
tablet_session_mode, kiosk_uid, current_uid. The OWL lock screen the OWL lock screen can detect whether the current browser session
branches on these, so a contract regression would silently belongs to the kiosk (locked) or a tech (unlocked). A contract
break the session-swap flow.""" regression on either field would silently break the session-swap
flow."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -25,15 +26,6 @@ class TestTilesBootstrapFields(HttpCase):
headers={'Content-Type': 'application/json'}, headers={'Content-Type': 'application/json'},
).json() ).json()
def test_tiles_returns_session_mode(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'tiles_test_pwd')
resp = self._jsonrpc('/fp/tablet/tiles', {})
result = resp.get('result', {})
self.assertIn('tablet_session_mode', result,
'tiles bootstrap must include tablet_session_mode for OWL branching')
# Default value is 'legacy' until rollout flips the flag
self.assertIn(result['tablet_session_mode'], ('legacy', 'session_swap'))
def test_tiles_returns_kiosk_uid(self): def test_tiles_returns_kiosk_uid(self):
self.authenticate('fp_tablet_kiosk@enplating.local', 'tiles_test_pwd') self.authenticate('fp_tablet_kiosk@enplating.local', 'tiles_test_pwd')
resp = self._jsonrpc('/fp/tablet/tiles', {}) resp = self._jsonrpc('/fp/tablet/tiles', {})